diff --git a/packages/webui/src/public/assistant.css b/packages/webui/src/public/assistant.css index 34d06f798..fc219d6d2 100644 --- a/packages/webui/src/public/assistant.css +++ b/packages/webui/src/public/assistant.css @@ -41,6 +41,11 @@ html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wra html[data-organizations] .assist-chips .ws-chip{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} html[data-organizations] .assist .pane-tool{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer;flex:none} html[data-organizations] .assist .pane-tool:hover{background:var(--raised);color:var(--text)} +/* SP7: each pane's own organization's Messages page, a text link among the pane's tools. */ +html[data-organizations] .assist .pane-tool.assist-msgs{width:auto;padding:0 8px;font-size:12px;font-weight:600;text-decoration:none} +/* SP7: "Resume assistant" under a pane's composer is a small secondary control, not a second primary button. */ +html[data-organizations] .assist-body .btn.pane-resume{align-self:flex-start;min-height:0;padding:2px 8px;margin-top:4px;font-size:12px;font-weight:500;background:none;border-color:transparent;color:var(--muted)} +html[data-organizations] .assist-body .btn.pane-resume:hover:not(:disabled){background:var(--raised);color:var(--text)} html[data-organizations] .assist .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} html[data-organizations] .assist-line{margin:0;padding:6px 12px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))} html[data-organizations] .assist-line[hidden]{display:none} diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index 517c3e463..46f42b9a6 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -7,6 +7,11 @@ // The open set, its order and the phone's shown pane live in the address // (#/assistant?open=:,…&show=:), checked before use and rewritten with // replaceState. A bare #/assistant opens the set this browser had last. +// +// SP7: a plain click on a thread link anywhere in the Console (a project's conversation rows, "Open +// conversation") opens that thread as a pane here, added to the open set; #/assistant?add=: +// carries it across the page change. A per-organization chat address typed or pasted still opens +// that organization's own chat view. (() => { 'use strict'; if (!document.documentElement.hasAttribute('data-organizations')) return; @@ -102,7 +107,6 @@
- Messages
@@ -128,6 +132,7 @@

Reading…

+ Messages
@@ -136,7 +141,7 @@ } const els = p => ({ title: p.el.querySelector('[data-assist-title]'), chips: p.el.querySelector('[data-assist-chips]'), - close: p.el.querySelector('[data-assist-close]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'), + close: p.el.querySelector('[data-assist-close]'), messages: p.el.querySelector('[data-assist-pane-messages]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'), }); // The picker: every organization on the list, its threads, and "New thread in …" where this @@ -186,19 +191,12 @@ function paintTop() { const { root, list, o, panes } = mounted; setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '

The organization list could not be read. Open panes only read.

'); - setText(root.querySelector('[data-assist-note]'), panes.length ? `${panes.length} open, up to ${MAX}` : ''); + setText(root.querySelector('[data-assist-note]'), mounted.notice || (panes.length ? `${panes.length} open, up to ${MAX}` : '')); const pick = root.querySelector('[data-assist-open]'); if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; } const full = panes.length >= MAX; pick.disabled = full; pick.title = full ? `${MAX} threads are open. Close one to open another.` : ''; - const here = window.MosaicOrg?.id, messages = root.querySelector('[data-assist-messages]'); - messages.hidden = !here; - if (here) { - const href = `#/orgs/${enc(here)}/messages`; - if (messages.getAttribute('href') !== href) messages.setAttribute('href', href); - setText(messages, `Messages in ${window.MosaicOrg.label || here}`); - } root.querySelector('[data-assist-empty]').hidden = panes.length > 0; } function drawPane(p) { @@ -211,6 +209,7 @@ p.title = title; setText(e.title, title); e.close.setAttribute('aria-label', `Close ${title}, ${name}`); + e.messages.setAttribute('aria-label', `Messages in ${name}`); if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style'); const project = thread && i.projects?.find(x => x.id === thread.project)?.name; setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`); @@ -251,7 +250,8 @@ if (seq !== p.seq || p.chat !== chat || !p.el.isConnected) return; const field = p.draft ? e.body.querySelector('form textarea') : null; if (p.draft && !field) { - // The thread reads but offers no composer: the held message stays visible here, never silently held. + // The thread reads but this render drew no composer (it cannot send now): the held message is + // quoted in the pane's status line until a composer is back to take it. p.held = true; const text = withHeld(); setText(e.line, text); e.line.hidden = false; return; } @@ -308,10 +308,51 @@ if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); } if (focus) els(p).title.setAttribute('tabindex', '-1'), els(p).title.focus({ preventScroll: true }); } + // SP7: a thread opened from a link takes a free slot or, with all three taken, the place of the + // first pane that holds no unsent message. When every pane holds one, nothing is replaced. + const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft(); + function place(key) { + const had = mounted.panes.find(p => p.key === key); + if (had) return had; + if (mounted.panes.length < MAX) { const p = makePane(key); mounted.panes.push(p); return p; } + const old = mounted.panes.find(p => !holds(p)); + if (!old) return null; + const p = makePane(key); + old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el); + mounted.panes[mounted.panes.indexOf(old)] = p; + return p; + } + const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message. Send or clear one, or close a pane, then open the thread again.`; + function add(key) { + if (!mounted) return false; + const p = place(key); + if (!p) { mounted.notice = REFUSED; setText(mounted.root.querySelector('[data-assist-note]'), REFUSED); return false; } + mounted.notice = ''; mounted.show = key; + writeUrl(); + if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); } + els(p).title.setAttribute('tabindex', '-1'); els(p).title.focus({ preventScroll: true }); + return true; + } + const addOf = query => { const v = new URLSearchParams(query || '').get('add'); return v ? parse(new URLSearchParams({ open: v }).toString()).keys[0] || null : null; }; + const THREAD_LINK = /^#\/(?:orgs\/([^/?#]+)\/)?(?:projects|chat)\/threads\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; + document.addEventListener('click', e => { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const a = e.target.closest?.('a[href]'); + if (!a || a.hasAttribute('download') || (a.target && a.target !== '_self')) return; + const m = THREAD_LINK.exec(a.getAttribute('href') || ''); + const org = m && (m[1] || window.MosaicOrg?.id); + if (!m || !org || !ORG.test(org)) return; + e.preventDefault(); + const key = `${org}:${m[2].toLowerCase()}`; + if (mounted && /^#\/assistant(?:\?|$)/.test(location.hash)) add(key); + else location.hash = `#/assistant?add=${key}`; + }); + function closePane(p) { if (!mounted || !p) return; const at = mounted.panes.indexOf(p); if (at < 0) return; + mounted.notice = ''; p.chat?.dispose(); p.chat = null; p.el.remove(); mounted.panes.splice(at, 1); const next = mounted.panes[Math.min(at, mounted.panes.length - 1)]; @@ -387,7 +428,7 @@ pick.value = ''; if (value.startsWith('new:')) { void openForm(value.slice(4)); return; } const want = parse(`open=${value}`).keys[0]; - if (want) addPane(want); + if (want) { mounted.notice = ''; addPane(want); } }); root.addEventListener('click', e => { if (!mounted) return; @@ -411,15 +452,19 @@ ++mountGen; container.innerHTML = frame(); const root = container.querySelector('[data-assistant]'); - mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0 }; + mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0, notice: '' }; bind(root); window.addEventListener('resize', fit); document.title = 'Assistant · Mosaic Console'; - if (navigated) root.querySelector('h1').focus({ preventScroll: true }); let want = parse(query); if (!want.named) want = parse(`open=${load() || ''}`); for (const key of want.keys) mounted.panes.push(makePane(key)); mounted.show = want.show || want.keys[0] || null; + // A thread link followed from another page: a fresh set holds no unsent message, so it always lands. + const extra = addOf(query), added = extra && place(extra); + if (added) mounted.show = extra; + if (added) { els(added).title.setAttribute('tabindex', '-1'); els(added).title.focus({ preventScroll: true }); } + else if (navigated) root.querySelector('h1').focus({ preventScroll: true }); writeUrl(); paintTabs(); layout(); root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0; await draw({ navigated: true }); @@ -446,6 +491,8 @@ mounted.show = want.show || want.keys[0] || null; writeUrl(); } + const extra = addOf(query); + if (extra) add(extra); } infos.clear(); void draw({ fresh: true }); @@ -454,5 +501,5 @@ // A write that changed an organization reads it again. document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } }); - window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft }); + window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft, add }); })(); diff --git a/packages/webui/src/public/brand.js b/packages/webui/src/public/brand.js index 90f67cdde..787f2b37c 100644 --- a/packages/webui/src/public/brand.js +++ b/packages/webui/src/public/brand.js @@ -184,8 +184,12 @@ draw(); // SP1: every page inside an organization says which one, after the dashboard: Dashboard / Acme / ... const box = $('breadcrumbs'), org = /^#\/orgs\/[^/?]+/.test(location.hash) ? window.MosaicOrg : null; - if (!box || !org?.id || box.querySelector('[data-crumb-org]') || /^#\/orgs\/[^/?]+\/organization(?:[/?]|$)/.test(location.hash)) return; - box.insertAdjacentHTML('afterbegin', `Dashboard${escape(org.label)}`); + if (box && org?.id && !box.querySelector('[data-crumb-org]') && !/^#\/orgs\/[^/?]+\/organization(?:[/?]|$)/.test(location.hash)) box.insertAdjacentHTML('afterbegin', `Dashboard${escape(org.label)}`); + // SP7: a trail with nothing above the page only repeats the page's heading, so the heading names it once. + if (!box) return; + const heading = [...$('main').querySelectorAll('h1')].find(h => h.getClientRects().length)?.textContent.trim(); + const echo = !box.querySelector('a') && !!heading && box.textContent.trim() === heading; + if (box.hidden !== echo) box.hidden = echo; } window.MosaicBreadcrumbs = breadcrumbs; function draw() { diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index a91257013..6f55818bd 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -317,7 +317,7 @@ const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role === 'human' ? 'You' : m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); const frame = pane ? '' : redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); // A pane leaves out the details: sessions and sharing are unavailable on an organization Console. - const resume = threadWrites() ? `` : ''; + const resume = threadWrites() ? `` : ''; const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; return `
    ${pane ? '' : `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    `}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '

    ' : ''}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${redesigned ? `${txt(t.instance)}` : `${txt(t.instance)} · Private conversation`}

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${pane ? resume : ''}${redesigned || pane ? '' : '

    Files

    Files unavailable.
    '}
    `; } diff --git a/packages/webui/src/public/processing.css b/packages/webui/src/public/processing.css index 5e390ade5..571854488 100644 --- a/packages/webui/src/public/processing.css +++ b/packages/webui/src/public/processing.css @@ -30,7 +30,7 @@ .avatar-wrap{position:relative}.avatar-wrap>button{display:flex;align-items:center;gap:8px;min-height:44px;border:0;background:transparent;color:var(--strong);font:inherit;cursor:pointer}.avatar-initial{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--action);color:var(--onAction);font-weight:700}.avatar-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:50;width:240px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:8px;box-shadow:var(--shadow)}.avatar-menu[hidden]{display:none}.avatar-menu a,.avatar-menu button,.avatar-menu label,.avatar-menu #local-sign-out{display:block;width:100%;padding:9px;color:var(--text);font:inherit;text-align:left}.avatar-menu a{text-decoration:none}.avatar-menu button{background:none;border:0;cursor:pointer}.avatar-menu a:hover,.avatar-menu button:hover{background:var(--raised)}.avatar-menu label{font-size:12px;color:var(--muted)}.avatar-menu select{display:block;width:100%;min-height:44px;margin-top:4px}.avatar-menu small{display:block;color:var(--muted)}.avatar-menu #local-sign-out{opacity:.7} #project-context,#project-tree,#board-tree{display:none!important} .s1-section{font-size:14px;gap:12px}.s1-section[aria-current=page]{background:color-mix(in srgb,var(--action) 30%,var(--surface));color:var(--strong)} -.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)} +.breadcrumbs[hidden]{display:none}.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)} #board-head h1{margin-bottom:0}#board-head p{margin:2px 0}.machine-off #status{display:none} #dashboard-summary,.dashboard-card{border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface)}#dashboard-summary{margin:20px 0}.kpi-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:18px 0}.kpi{padding:0 18px;border-right:1px solid var(--line);min-width:0}.kpi:last-child{border-right:0}.kpi div{font-size:12px;color:var(--strong);font-weight:700;min-height:36px}.kpi strong{display:block;font-size:28px;line-height:1.2;color:var(--strong)}.kpi span{display:block;color:var(--muted);font-size:12px;margin-top:4px}.kpi-footer{border-top:1px solid var(--line);padding:8px 18px;margin:0;color:var(--muted);font-size:12px} #dashboard-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:20px 0}.dashboard-card{padding:16px}.dashboard-card h2{margin:0 0 10px}.dashboard-card .processing-controls{padding:0;border:0}.dashboard-card .processing-controls strong{display:none}.dashboard-card .processing-controls .btn{min-height:44px}.dashboard-card ul{padding-left:20px}.dashboard-card a{color:var(--action)} diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js index e97854072..9861b1be8 100644 --- a/packages/webui/src/public/projects.js +++ b/packages/webui/src/public/projects.js @@ -202,22 +202,14 @@ return `

    Reviewed deliveries ${f.reviewed.length}

    ${f.reviewed.length?f.reviewed.map(({r,file})=>`
    ${clean(file.name)} Reviewed

    Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.

    Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.

    `).join(''):`

    ${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}

    `}

    Draft workspace ${f.drafts.length}

    Names only. Draft files may change and are not reviewed deliveries.

    ${f.drafts.length?f.drafts.map(({r,name})=>`
    ${clean(name)}Request ${clean(r.id)} · Draft
    `).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'

    Could not read draft workspace. Try again later.

    ':'

    No draft files found.

    '}${data.requests.some(r=>r.workspace?.truncated)?'

    The draft list was truncated.

    ':''}

    Project documents ${docs?`${docs.length}`:''}

    ${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`
    ${clean(name)}Open in Docs
    `).join(''):'No project documents found.'}
    `; } const expandedPrds = new Set(); - // U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two - // panes showing the same project (or two organizations' projects with one id) never share them. - // The pane passes `views`, kept on the pane per organization, so the state outlives a repaint - // that replaces the content element (a new conversation changes the header). Without it, the - // state lives as long as the container. - const paneViews = new WeakMap(); - const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);}; - function requirementView(data,project,selected,expandedSet=expandedPrds,idPrefix='') { - const pid=esc(idPrefix); + function requirementView(data,project,selected,expandedSet=expandedPrds) { const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft; const north=typeof data.north?.content==='string'?data.north.content:null; - const select=options.length>1?``:''; + const select=options.length>1?``:''; const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id); const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null; const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`
    ${label}${label==='PRD file' && docLink?`${clean(value)}`:`${clean(value)}`}
    `).join('') : '

    Unavailable until a PRD is read.

    '; - return `${r?`
    ${select || `Request ${clean(r.id)}`} · ${chip(r.state)} · from ${clean(r.thread.name)} Open conversation
    `:select}

    Project direction

    North star

    ${north!==null?`
    Open north star in Docs`:data.northRecordsMissing?`

    ${window.MosaicRecords.markup(project.repo)}

    `:data.northMissing?'

    No north star yet.

    ':project.repo?'

    North star unavailable. Docs could not read this project’s repository.

    ':`

    This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.

    `}

    ${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`${d.snapshot.id?'Approved':'Draft'}`:''}

    ${d?`

    ${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}

    Requirements ${d.requirements.length}

    ${d.requirements.map((req,i)=>`
    ${clean(req.id)} · ${clean(req.title)}

    ${clean(req.text)}

    Requirement identity: ${clean(candidate.requirements[i]?.path)} · ${clean(candidate.requirements[i]?.blob)}

    `).join('')}
    `:`

    ${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}

    `}${d && data.requests.some(x=>x.materialFailed)?'

    Some request PRDs could not be read. Refresh to try again.

    ':''}
    `; + return `${r?`
    ${select || `Request ${clean(r.id)}`} · ${chip(r.state)} · from ${clean(r.thread.name)} Open conversation
    `:select}

    Project direction

    North star

    ${north!==null?`
    Open north star in Docs`:data.northRecordsMissing?`

    ${window.MosaicRecords.markup(project.repo)}

    `:data.northMissing?'

    No north star yet.

    ':project.repo?'

    North star unavailable. Docs could not read this project’s repository.

    ':`

    This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.

    `}

    ${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`${d.snapshot.id?'Approved':'Draft'}`:''}

    ${d?`

    ${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}

    Requirements ${d.requirements.length}

    ${d.requirements.map((req,i)=>`
    ${clean(req.id)} · ${clean(req.title)}

    ${clean(req.text)}

    Requirement identity: ${clean(candidate.requirements[i]?.path)} · ${clean(candidate.requirements[i]?.blob)}

    `).join('')}
    `:`

    ${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}

    `}${d && data.requests.some(x=>x.materialFailed)?'

    Some request PRDs could not be read. Refresh to try again.

    ':''}
    `; } function overview(data,project,canSend,receipt='') { const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads]; @@ -260,9 +252,7 @@ const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index]; focus?.focus({preventScroll:true}); } - // A Side by side pane passes `idPrefix` (pane-a-, pane-b-), so the same project in both panes - // draws distinct ids; the full view passes none and keeps its ids. - async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null,idPrefix=''}) { + async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null}) { if(!container || !project) return; const generation=epoch, run=(contentRuns.get(container) || 0)+1; contentRuns.set(container,run); @@ -270,11 +260,10 @@ const data=await gather(project,threads,org).catch(()=>null); if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return; if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;} - const views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded; const selected=selectedRequests.get(project.id); if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id); const selection=selectedRequests.get(project.id); - const view = section==='requirements'?requirementView(data,project,selection,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt); + const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt); const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id)); // The generated presentation and separately mounted Markdown determine visible content. const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]); @@ -284,7 +273,7 @@ // Retained controls (the request selector) draw from the newest accepted read, even when // this read changes nothing visible and the DOM is kept. contentModels.set(container,{data,project,section,canSend,roomReceipt}); - if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw(). + window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw(). if (contentPaints.get(container) === signature) return; contentPaints.set(container, signature); // Capture at commit time: the user may have interacted while the read was pending. @@ -292,12 +281,12 @@ const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null; const draw=selected=>{ const {data,project,section,canSend,roomReceipt}=contentModels.get(container); - container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt); + container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt); if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','

    Some request data could not be read; this list may be incomplete.

    '); const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id)); const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); } const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); } - const requestSelect='#'+CSS.escape(idPrefix+'project-request-select'); + const requestSelect='#project-request-select'; container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});}); }; draw(selection); @@ -305,8 +294,8 @@ if (restore) { restoreView(container,restore); } - const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section'); - if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'}); + const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section'); + if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'}); } // Cut at a word boundary, unless that would leave only a markdown marker (a long heading). function previewCut(content,max) { diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 7b5cb57f9..ba6a5fb6f 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -462,7 +462,7 @@ html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:cente /* Page frame (sample main column). */ html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px} -body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none} +body:has([data-org-dashboard]) #breadcrumbs{display:none} html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0} html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)} html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none} @@ -567,7 +567,7 @@ html[data-organizations] .ws-rules .ws-lvl{margin-right:0} html[data-organizations] .ws-scope{margin:-8px 0 14px} /* Across organizations (U3): the main dashboard, projects and inbox, samples 02, 04 and 05. */ -body:has([data-ws-home]) #breadcrumbs,body:has([data-ws-projects]) #breadcrumbs,body:has([data-ws-inbox]) #breadcrumbs{display:none} +body:has([data-ws-home]) #breadcrumbs{display:none} html[data-organizations] .ws-edge{box-shadow:inset 3px 0 0 var(--org)} html[data-organizations] .ws-orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600} html[data-organizations] .ws-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;line-height:1.5} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index a6cdba793..ad24e00f3 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -236,21 +236,19 @@ const restates = reason => /^the bus is not (running|answering)\.?$/i.test(reason.trim()); const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : ''); // #1873 SP2: this is the page's only bus banner (the shell's "Bus unavailable" hides on this page), so - // it carries the restart action where the Console allows it. A side-by-side pane has no restart. - function downBanner(org, row, pane = false) { + // it carries the restart action where the Console allows it. + function downBanner(org, row) { const stopped = org.busStopped === true; const plain = row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason && restates(row.reason); const why = plain ? '' : downWhy(row); const tail = plain ? (row.boot?.at ? ` Last start ${esc(when(row.boot.at))}.` : '') : why ? ` Reason: ${esc(why)}.` : ' No reason was read.'; - const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart the bus') || ''; + const restart = window.MosaicConsole?.busButton?.('Restart the bus') || ''; return ``; } - // 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 = {}) { - const pane = opts.pane === true, why = `${opts.idPrefix || ''}ws-reload-why`; + async function dashboard(id = window.MosaicOrg.id) { + const why = 'ws-reload-why'; // Only an organization the membership list names is read; anything else reads nothing more. const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]); const list = Array.isArray(listRead) ? listRead : null; @@ -271,11 +269,12 @@ const tracker = business?.business?.vars?.['tracker.kind']; // Header: the bus restart is console-restart.js's button where the Console allows it, otherwise - // the same control, present and disabled, with the reason. - const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || ''; - const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail"; - const stopTitle = pane ? "Stop the bus from this organization's full view" : "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail"; - const bus = restart ? `${restart}` : ` `; + // the same control, present and disabled, with the reason. SP7 (#1873): while the bus is down and + // the down banner carries the live restart, the header draws none, so the page has one. + const restart = window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || ''; + const offTitle = "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail"; + const stopTitle = "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail"; + const bus = restart ? (org.busHealthy ? `${restart}` : '') : ` `; const reload = ``; const [bcls, bglyph] = state(org); const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; @@ -314,7 +313,7 @@ return `
    ${av(org, 'lg')}

    ${name}

    ${bus}${reload}
    ${meta}
    - ${org.busHealthy ? '' : downBanner(org, downRow, pane)} + ${org.busHealthy ? '' : downBanner(org, downRow)} ${docrow}
    @@ -590,7 +589,9 @@ const bar = (o, parts, style = '') => ``; const by = (o, parts, fmt = n => n) => `
    ${parts.map(p => `${av(o.meta(p.id))}${esc(o.name(p.id))}${esc(fmt(p.n))}`).join('')}
    `; const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' }; - const classChip = c => (c ? `${esc(c)}` : ''); + // The one map of decision classes to words: the class chips and the Inbox Class filter both read it. + const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' }; + const classChip = c => (c ? `${esc(TYPE_WORD[c] || c)}` : ''); const itemKey = d => `${d.org}:${d.id}`; const roleOf = d => d.session?.role || d.raisedBy || ''; const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · '); @@ -802,7 +803,6 @@ // function that returns the hire, fire and change form bus.js builds for the current organization // (D4, P0-1), shown on the scoped list only. It is called after the read so typing or a submit // result that lands during the read is not painted over with an older draft. - const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' }; const TYPE_RE = /^[a-z][a-z-]{0,31}$/; async function inboxPage(q, opts = {}) { const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null; diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index ce3f388ab..df0e0193a 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -86,6 +86,7 @@ const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.d const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`; const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`; const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`; +const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)'; const SENT = '/Accepted message|Message shown in this conversation/'; const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`); @@ -96,6 +97,8 @@ test('the sidebar Assistant opens one system page, and two threads from two orga await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`); await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`); assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page'); + // SP7: the page name shows once, as the heading. + assert.equal(await b.evaluate(trail), null); await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`); await pick(b, `alpha:${T.a1}`); await pick(b, `beta:${T.b1}`); @@ -279,3 +282,69 @@ test('"Open thread" starts a new thread in a chosen organization through that or assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]); assert.equal(await b.evaluate(query), `beta:${NEW}`); }); + +// SP7: a thread link anywhere opens that thread as a pane here; a typed per-organization address +// still opens that organization's own chat view. +const follow = (b, href) => b.evaluate(`(() => { const a = document.createElement('a'); a.href = ${JSON.stringify(href)}; a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`); +const type = (b, sel, text) => b.evaluate(`(() => { const el = ${PANE(sel)}.querySelector('form textarea'); el.value = ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`); +const field = sel => `${PANE(sel)}?.querySelector('form textarea')`; + +test('SP7: a thread link on a project page opens the thread as a pane on the Assistant page, added to the open set', async t => { + const { b, wait } = await open(t, { hash: `#/assistant?open=beta:${T.b1}` }); + await wait(ready(`beta:${T.b1}`)); + await b.evaluate(`location.hash = '#/orgs/alpha/projects/${P.alpha}'`); + await wait(`!!document.querySelector('#bus-view a[href$="/threads/${T.a1}"]')`); + await b.evaluate(`document.querySelector('#bus-view a[href$="/threads/${T.a1}"]').click()`); + await wait(`location.hash.startsWith('#/assistant') && ${ready(`alpha:${T.a1}`)}`); + assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a1}`]); + assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a1}`); + // A per-organization chat address typed or pasted still opens that organization's chat view. + await b.evaluate(`location.hash = '#/orgs/beta/projects/threads/${T.b1}'`); + await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`); + assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`); +}); + +test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state }); + await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && !${field(`alpha:${T.a1}`)}.disabled`); + await type(b, `alpha:${T.a1}`, 'alpha draft'); + // On the Assistant page a thread link adds a pane in place: the typed message stays. + await follow(b, `#/orgs/alpha/projects/threads/${T.a2}`); + await wait(ready(`alpha:${T.a2}`)); + assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]); + assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft'); + // A fourth: Alpha's first pane holds a message, so Beta's pane, the first without one, gives way. + await follow(b, `#/orgs/home/projects/threads/${T.h1}`); + await wait(ready(`home:${T.h1}`)); + assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]); + assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`); + assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft'); + // All three hold a message: nothing is replaced and the page says why. + await wait(`!${field(`home:${T.h1}`)}.disabled && !${field(`alpha:${T.a2}`)}.disabled`); + await type(b, `home:${T.h1}`, 'home draft'); + await type(b, `alpha:${T.a2}`, 'kickoff draft'); + await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); + await wait(`/not opened/.test(document.querySelector('[data-assist-note]').textContent)`); + assert.match(await b.evaluate(`document.querySelector('[data-assist-note]').textContent`), /all 3 panes hold an unsent message/); + assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]); + assert.deepEqual(await b.evaluate(`[${field(`alpha:${T.a1}`)}.value, ${field(`home:${T.h1}`)}.value, ${field(`alpha:${T.a2}`)}.value]`), ['alpha draft', 'home draft', 'kickoff draft']); + assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`); + // Closing a pane clears the reason; the thread then opens. + await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`); + await wait(`!/not opened/.test(document.querySelector('[data-assist-note]').textContent)`); + await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); + await wait(ready(`beta:${T.b1}`)); + assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]); + assert.deepEqual(state.sent, []); +}); + +test('SP7: each pane names its own organization\'s Messages page, and the bar no longer names the organization opened last', async t => { + const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks' }); + await wait(`window.MosaicOrg?.id === 'beta'`); + await b.evaluate(`location.hash = '#/assistant?open=alpha:${T.a1},beta:${T.b1}'`); + await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-messages]'); return a && [a.getAttribute('href'), a.getAttribute('aria-label'), a.textContent.trim()]; })`), + [['#/orgs/alpha/messages', 'Messages in Alpha', 'Messages'], ['#/orgs/beta/messages', 'Messages in Beta', 'Messages']]); + assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/); +}); diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs index ed779ffad..79154ae4a 100644 --- a/packages/webui/tests/single-pane-browser.test.mjs +++ b/packages/webui/tests/single-pane-browser.test.mjs @@ -371,6 +371,10 @@ test('a stopped organization\'s page shows one bus banner, with the restart acti assert.match(shown[0], /The bus is not running\. Nothing about Gamma Home's agents or packs can be read until it starts\./); assert.match(shown[0], /Restart the bus/); assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true); + // SP7 item 7: one restart control on the page, the banner's; the header draws no second one. + const restarts = 'Array.from(document.querySelectorAll("[data-bus-restart], [data-bus-restart-off]")).filter(e => e.getClientRects().length).map(e => e.closest("[data-org-down]") ? "banner" : "header")'; + assert.deepEqual(await b.evaluate(restarts), ['banner']); + assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead [data-dashboard-bus]")'), null); // The organization's other pages keep the shell's banner, with its restart. await b.evaluate('location.hash = "#/orgs/gamma/agents"'); await wait('!document.getElementById("bus-down").hidden && /Bus unavailable/.test(document.getElementById("bus-down").textContent)'); diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs index c696e45b5..0b153b08d 100644 --- a/packages/webui/tests/system-agents-browser.test.mjs +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -102,6 +102,7 @@ async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = fal } const rows = 'JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent))'; const are = list => `${rows} === ${JSON.stringify(JSON.stringify(list))}`; +const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)'; const ALL = ['acme:chief', 'acme:coder', 'beta:pm', 'beta:research-2']; test('#/agents lists both organizations\' agents in one table, each row with its organization chip', async t => { @@ -111,6 +112,8 @@ test('#/agents lists both organizations\' agents in one table, each row with its assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/agents'); assert.equal(await text('#bus-view h1'), 'Agents'); + // SP7: the page name shows once, as the heading; a trail with nothing above it is not drawn. + assert.equal(await b.evaluate(trail), null); assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-orgchip]'), 'Acme Studio'); assert.equal(await text('[data-ws-agent="beta:pm"] [data-ws-orgchip]'), 'Beta Works'); // State from the bus, and what the agent works on: its task, with the task's project chip. @@ -206,6 +209,8 @@ test('#/orgs//agents shows that organization\'s rows only, with a link to ev assert.match(await text('[data-ws-agents] .sub'), /Beta Works/); assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] a[href=\'#/agents\']") !== null'), true); assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents'); + // SP7: a drill-in keeps its trail back to the dashboard and the organization. + await wait(`${trail} === 'Dashboard / Beta Works / Agents'`); // The organization's own levels grid and held roles still follow, under the one page heading. await wait('document.querySelector("[data-org-agents]")'); assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1); diff --git a/packages/webui/tests/system-inbox-browser.test.mjs b/packages/webui/tests/system-inbox-browser.test.mjs index f2125a5b9..f260057e6 100644 --- a/packages/webui/tests/system-inbox-browser.test.mjs +++ b/packages/webui/tests/system-inbox-browser.test.mjs @@ -99,6 +99,7 @@ async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = fals await b.navigate(`${base}/${hash}`); return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; } +const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)'; const items = 'JSON.stringify([...document.querySelectorAll("[data-ws-item]")].map(r => r.dataset.wsItem))'; test('#/inbox lists both organizations in one list, newest first, each row with its organization and project chips', async t => { @@ -108,11 +109,15 @@ test('#/inbox lists both organizations in one list, newest first, each row with assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/inbox'); assert.equal(await text('#bus-view h1'), 'Inbox'); + // SP7: the page name shows once, as the heading. + assert.equal(await b.evaluate(trail), null); // Organization chips on every row; the project chip only where the item has a project. assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-orgchip]`), 'Acme Studio'); assert.equal(await text(`[data-ws-item="beta:${BETA_DECISION}"] [data-ws-orgchip]`), 'Beta Works'); assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-projchip]`), 'Website'); assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null); + // SP7: the class chip says the decision class in words, from the one map of class words. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-ws-item]')].map(r => r.querySelector('td.hide-m .ws-chip')?.textContent)`), ['Within role', 'Gated']); // The stopped organization is named at the top, before the list, with its reason. const note = await text('[data-ws-unread]'); assert.match(note, /Gamma Home/); @@ -157,6 +162,7 @@ test('answering beta\'s row from the list while acme was opened last reaches bet assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`); await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}" && document.querySelector("#decide-section [data-decide]")`); + assert.equal(await text('[data-ws-insp] header .ws-chip'), 'Within role'); // The item also opens in its own organization. assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] [data-ws-open]")?.getAttribute("href")'), `#/orgs/beta/inbox/${BETA_DECISION}`); await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`); @@ -183,6 +189,8 @@ test('#/orgs//inbox is the same list filtered to that organization', async t await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`); assert.match(await text('[data-ws-inbox] .sub'), /Beta Works/); assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] a[href=\'#/inbox\']") !== null'), true); + // SP7: a drill-in keeps its trail. + await wait(`${trail} === 'Dashboard / Beta Works / Inbox'`); // Selecting a row keeps the organization address. await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`); await wait(`location.hash.startsWith("#/orgs/beta/inbox?") && document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}"`); diff --git a/packages/webui/tests/system-tasks-browser.test.mjs b/packages/webui/tests/system-tasks-browser.test.mjs index 1ca6fc999..70082c723 100644 --- a/packages/webui/tests/system-tasks-browser.test.mjs +++ b/packages/webui/tests/system-tasks-browser.test.mjs @@ -92,6 +92,7 @@ async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = fals await b.navigate(`${base}/${hash}`); return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; } +const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)'; const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())'; const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3'])); const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3'])); @@ -103,6 +104,8 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its assert.equal(await b.evaluate('location.hash'), '#/tasks'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks'); assert.equal(await text('#bus-view h1'), 'Tasks'); + // SP7: the page name shows once, as the heading. + assert.equal(await b.evaluate(trail), null); // The per-organization page's columns. assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']); // One chip names the organization, with its avatar in place of the dot. @@ -172,6 +175,8 @@ test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organizat await wait(`${cards} === ${BETA}`); assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks'); assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta'); + // SP7: a drill-in keeps its trail. + await wait(`${trail} === 'Dashboard / Beta Works / Tasks'`); // No filter is on, so no "match the filters" line: the scope is not a filter. assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null); assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null);