From a9912983e6e72cb4f45fb4879c3228a6afcc0898 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 06:41:07 -0600 Subject: [PATCH] fix(webui): sp7 re-check, a narrow pane wraps its header tools inside the pane, the project page's Review decision and request links keep the conversation's full view, Full view names its thread (#1873, lane sp7-polish) --- packages/webui/src/public/assistant.css | 5 +++-- packages/webui/src/public/assistant.js | 3 ++- packages/webui/src/public/projects.js | 4 ++-- .../webui/tests/assistant-threads-browser.test.mjs | 12 +++++++++--- .../webui/tests/projects-review-browser.test.mjs | 3 +++ 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/public/assistant.css b/packages/webui/src/public/assistant.css index a3708e91b..31a5c480a 100644 --- a/packages/webui/src/public/assistant.css +++ b/packages/webui/src/public/assistant.css @@ -40,8 +40,9 @@ html[data-organizations] .assist-pane-h h2{margin:0;font-size:15px;font-weight:7 html[data-organizations] .assist-pane-h h2:focus{outline:none} html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wrap;min-width:0;order:1;flex:1 0 100%} html[data-organizations] .assist-chips:empty{display:none} -/* SP7: a pane's tools (Full view, Messages, close) stay together on the title's row; the chips take the row under it. */ -html[data-organizations] .assist-pane-tools{display:inline-flex;align-items:center;gap:2px;flex:none} +/* SP7: a pane's tools (Full view, Messages, close) stay together on the title's row; the chips take the row under it. + A pane too narrow for them wraps the tools, never pushes one past the pane's edge. */ +html[data-organizations] .assist-pane-tools{display:inline-flex;align-items:center;justify-content:flex-end;gap:2px;flex:0 1 auto;flex-wrap:wrap;max-width:100%;margin-left:auto} 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)} diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index 0ce40b715..27abd59de 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -144,7 +144,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]'), messages: p.el.querySelector('[data-assist-pane-messages]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'), + close: p.el.querySelector('[data-assist-close]'), full: p.el.querySelector('[data-assist-pane-full]'), 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 @@ -218,6 +218,7 @@ p.title = title; setText(e.title, title); e.close.setAttribute('aria-label', `Close ${title}, ${name}`); + e.full.setAttribute('aria-label', `Full view of ${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; diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js index d694f6e37..70865962c 100644 --- a/packages/webui/src/public/projects.js +++ b/packages/webui/src/public/projects.js @@ -216,8 +216,8 @@ if(first) threads.sort((a,b)=>Number(b.id===first.thread.id)-Number(a.id===first.thread.id)); const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–'; const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft; - const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]]; - return `${waiting.length?`
${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. Review decision
`:''}
${metrics.map(([title,value,caption,href])=>`${title}${value}${clean(caption)}`).join('')}

Updated just now · Showing requests from recent messages.

Current work

${canSend&&!project.archived?`${plusIcon}New conversation`:''}
${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`${clean(t.name)}With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}${workFor(data,t)?chip(workFor(data,t).state):''}`).join(''):'

No conversations yet. Start one to begin work.

'}

Tasks ${taskRefs(data).length}

${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`
${taskLink(ref,data)}
`).join(''):data.failed?'

Request tasks unavailable. Refresh to try again.

':'

No request tasks available.

'}View all project tasks
`; + const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations'),!!first],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations'),!!waiting[0]],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]]; + return `${waiting.length?`
${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. Review decision
`:''}
${metrics.map(([title,value,caption,href,full])=>`${title}${value}${clean(caption)}`).join('')}

Updated just now · Showing requests from recent messages.

Current work

${canSend&&!project.archived?`${plusIcon}New conversation`:''}
${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`${clean(t.name)}With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}${workFor(data,t)?chip(workFor(data,t).state):''}`).join(''):'

No conversations yet. Start one to begin work.

'}

Tasks ${taskRefs(data).length}

${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`
${taskLink(ref,data)}
`).join(''):data.failed?'

Request tasks unavailable. Refresh to try again.

':'

No request tasks available.

'}View all project tasks
`; } function tasksView(data) { const refs=taskRefs(data); diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index 79f2ffe39..e265c43d6 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -358,6 +358,9 @@ test('SP7: each pane links its thread\'s full view, and one plain click reaches 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-full]'); return a && [a.getAttribute('href'), a.textContent.trim(), a.hasAttribute('data-full-view')]; })`), [[`#/orgs/alpha/projects/threads/${T.a1}`, 'Full view', true], [`#/orgs/beta/projects/threads/${T.b1}`, 'Full view', true]]); + // Each pane's Full view names its thread and organization, so a screen reader can tell three apart. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane-full]')].map(a => a.getAttribute('aria-label'))`), + ['Full view of Launch email, Alpha', 'Full view of Gateway auth, Beta']); await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('a[data-assist-pane-full]').click()`); 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}`); @@ -385,11 +388,14 @@ const headerRows = `[...document.querySelectorAll('[data-assist-pane]')].map(p = const inside = [...p.querySelectorAll('.assist-pane-h > *, .assist-pane-h a, .assist-pane-h button')].every(e => { const q = e.getBoundingClientRect(); return q.left >= h.left - 1 && q.right <= h.right + 1; }); return { oneRow: Math.max(...row) - Math.min(...row) <= 4, chipsBelow: r('[data-assist-chips]').top >= r('[data-assist-title]').bottom - 1, inside }; })`; -for (const [width, height, keys] of [[1440, 900, [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]], [390, 844, [`beta:${T.b1}`]]]) { - test(`SP7: a pane header keeps its title and tools on one row with the chips below, at ${width}`, async t => { +// At 800 three panes are too narrow for one row: the tools wrap, and none of them leaves the pane. +const THREE = [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]; +for (const [width, height, keys, oneRow] of [[1440, 900, THREE, true], [800, 900, THREE, null], [761, 900, THREE, null], [390, 844, [`beta:${T.b1}`], true]]) { + test(`SP7: a pane header keeps its tools inside the pane${oneRow ? ', title and tools on one row with the chips below' : ''}, at ${width}`, async t => { const { b, wait } = await open(t, { width, height, hash: `#/assistant?open=${keys.join(',')}` }); await wait(keys.map(k => ready(k)).join(' && ')); await wait(`[...document.querySelectorAll('[data-assist-pane] [data-ws-orgchip]')].length === ${keys.length}`); - assert.deepEqual(await b.evaluate(headerRows), keys.map(() => ({ oneRow: true, chipsBelow: true, inside: true }))); + const rows = await b.evaluate(headerRows); + assert.deepEqual(rows.map(r => (oneRow ? r : { ...r, oneRow: null })), keys.map(() => ({ oneRow, chipsBelow: true, inside: true }))); }); } diff --git a/packages/webui/tests/projects-review-browser.test.mjs b/packages/webui/tests/projects-review-browser.test.mjs index c97f7da87..485e8414c 100644 --- a/packages/webui/tests/projects-review-browser.test.mjs +++ b/packages/webui/tests/projects-review-browser.test.mjs @@ -168,3 +168,6 @@ test('S4 fix stale project gather cannot enrich a newer request revision', async // SP7 review: the requirement view sends the person to the conversation's Request details, which a pane // does not have, so both of its "Open conversation" links are marked as the full view. test('SP7 requirement view links the conversation\'s full view, not a pane',async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/requirements');await wait(`document.querySelector('[data-prd]')`);const links=await b.evaluate(`[...document.querySelectorAll('[data-project-content] a')].filter(a=>a.textContent.trim()==='Open conversation').map(a=>a.hasAttribute('data-full-view'))`);assert.deepEqual(links,[true,true]);}); +// SP7 re-check: the overview's "Review decision" and its two request counts lead to a request, which only +// the conversation's full view shows, so each link that names a conversation is marked as the full view. +test('SP7 overview decision and request links go to the conversation\'s full view',async t=>{const {b,wait}=await setup(t,'/projects');await b.evaluate(`reviewStates['request-1'].wait={kind:'decision'};document.querySelector('#refresh').click();location.hash='#/projects/${P}'`);await wait(`document.querySelector('.project-decision a')`);const got=await b.evaluate(`({decision:[...document.querySelectorAll('.project-decision a')].map(a=>[a.textContent.trim(),a.hasAttribute('data-full-view'),/\\/threads\\//.test(a.getAttribute('href'))]),metrics:[...document.querySelectorAll('.project-metrics a')].map(a=>[a.querySelector('span').textContent,/\\/threads\\//.test(a.getAttribute('href')),a.hasAttribute('data-full-view')])})`);assert.deepEqual(got,{decision:[['Review decision',true,true]],metrics:[['Requests in progress',true,true],['Needs your input',true,true],['Conversations',false,false],['Files',false,false]]});});