diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index e7a2353494..5703207efd 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -80,7 +80,7 @@ "smoke:task-inspector-route": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/task-inspector-route-smoke.mjs", "smoke:bound-conversation": "vite build --ssr smoke/bound-conversation-smoke.ts --outDir node_modules/.cache/loopx-bound-conversation --emptyOutDir && node node_modules/.cache/loopx-bound-conversation/bound-conversation-smoke.js", "smoke:delegation-search": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/delegation-search-browser-smoke.mjs", - "smoke:workspace-command-palette": "node --experimental-strip-types --test src/features/personal-workspace/workspace-command-entries.test.mjs && node --experimental-strip-types smoke/workspace-command-palette-browser-smoke.mjs" +"smoke:workspace-command-palette": "node --experimental-strip-types --test src/features/personal-workspace/workspace-command-entries.test.mjs && node --experimental-strip-types smoke/workspace-command-palette-browser-smoke.mjs && node --experimental-strip-types smoke/workspace-navigation-browser-smoke.mjs" }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/README.md b/apps/presentation/dashboard/smoke/assets/workspace-navigation/README.md new file mode 100644 index 0000000000..82da14c6d6 --- /dev/null +++ b/apps/presentation/dashboard/smoke/assets/workspace-navigation/README.md @@ -0,0 +1,42 @@ +# Workspace navigation reading evidence + +Synthetic populated workspace, including 30 Goals and a linked read-only archive. +The packaged Chat bundle is served by the existing production Chat entrypoint; +API discovery/permission descriptors are synthetic. These captures contain no +private operating state and do not establish an installed App result. + +The baseline is `c10d8c2b4a20747e6eb77a188fd8e870b3be19de`. It reproduces the +missing host Settings entry while viewing the archived Goal. The after views +use the accompanying source and the same fixture. Desktop is 1440×900, low +window 1280×540, and narrow 390×640. Narrow captures show the opened sidebar. +The default footer now has Settings and a compact search icon on one row. +The independent Goal list scrolls above it; update status retains its own row. +Restriction views show an explicit Goal target and its original read-only +boundary, without local management forms. Repeated matching target/source +labels are shown once. Scope and source remain available. + +Reproduce after a Chat build with `npm run smoke:workspace-command-palette` in +the dashboard package and `LOOPX_PERSONAL_WORKSPACE_PACKAGED=1`. The navigation +scenario covers EN/ZH, low/narrow windows, real browser touch events, keyboard, +IME-shaped events, focus, outside dismissal, internal selection/drag/scroll, +no click-through and zero write requests. It does not certify native macOS IME +or touch hardware. `LOOPX_NAVIGATION_CAPTURE_BASE=1` is only the baseline +capture/reproduction mode; it does not run the new behavior assertions. + +Whole-page judgment: the stable lower row makes Settings discoverable while +reading the archive and removes a competing navigation card. Actual values, +required permissions, action outcomes and recovery remain direct. Coverage, +missing-observation and methodology explanations use contextual glyphs, with +complete hover, focus and tap readback. The empty/partial Overview no longer +stacks duplicate disclaimers. Help uses body text and stays within the viewport. +Research captures use the real read-only HTTP owner with synthetic measurements +and long analysis; judgments, unknown values and original citations are retained. +The unanalysed-scope glyph sits beside the result heading. The expanded phone +view shows the original full rationale and evidence. This is whole-page visual +inspection and interaction evidence, not a human comprehension study or a +qualification of every setting, platform or native input method. + +The glyph and research phone captures are 390×844. The research desktop is +1280×900. Reproduce research views with the packaged `research-history` browser +scenario. Both before and after captures are synthetic; archived source names +are fixture labels, not a claim that benchmark work was executed. diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-desktop.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-desktop.png new file mode 100644 index 0000000000..34ea04b0bb Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-low.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-low.png new file mode 100644 index 0000000000..014234e2c7 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-low.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-narrow.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-narrow.png new file mode 100644 index 0000000000..cfb2567608 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-restriction-desktop.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-restriction-desktop.png new file mode 100644 index 0000000000..13c3cd1997 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-restriction-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-restriction-narrow.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-restriction-narrow.png new file mode 100644 index 0000000000..08538bdc9b Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/after-restriction-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/before-desktop.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/before-desktop.png new file mode 100644 index 0000000000..5bad9369c3 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/before-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/before-narrow.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/before-narrow.png new file mode 100644 index 0000000000..4225088c88 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/before-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-default-desktop.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-default-desktop.png new file mode 100644 index 0000000000..8a4187a954 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-default-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-default-narrow.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-default-narrow.png new file mode 100644 index 0000000000..0055a2ba60 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-default-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-help-narrow.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-help-narrow.png new file mode 100644 index 0000000000..8370ae5618 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/glyph-help-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-default-desktop.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-default-desktop.png new file mode 100644 index 0000000000..557a0c1a7d Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-default-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-default-narrow.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-default-narrow.png new file mode 100644 index 0000000000..e7f921f9d7 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-default-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-expanded-narrow.png b/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-expanded-narrow.png new file mode 100644 index 0000000000..e972db7068 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/workspace-navigation/research-expanded-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs index 14b29592a4..1648e9c9d7 100644 --- a/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs @@ -105,6 +105,10 @@ try { await page.keyboard.press('Tab'); assert.equal(await dialogFor(page).evaluate(element => element.contains(document.activeElement)), true); } + if (await page.locator('.context-help-popover:popover-open').count()) { + await page.keyboard.press('Escape'); + assert.equal(await dialogFor(page).isVisible(), true, 'Tab-focused help closes before the palette'); + } await page.keyboard.press('Escape'); await page.locator('.personal-mobile-menu').click(); await launcher.click(); @@ -147,8 +151,13 @@ try { }}); try { await remote.page.locator('.personal-goal-row').first().locator('.personal-goal-link').click(); + assert.equal(await remote.page.getByRole('button', {name:/^(Settings|设置)$/}).count(), 0, 'a read-only host does not expose local management'); await openPalette(remote.page); - assert.match(await dialogFor(remote.page).innerText(), /Read-only source|只读来源/); + const remoteHelp = dialogFor(remote.page).getByRole('button', {name:/Navigation help|导航说明/}); + await remoteHelp.focus(); + assert.match(await remote.page.locator('.context-help-popover:popover-open').innerText(), /Read-only source|只读来源/); + await remote.page.keyboard.press('Escape'); + assert.equal(await dialogFor(remote.page).isVisible(), true, 'Help closes before the surrounding palette'); assert.equal(await dialogFor(remote.page).getByRole('option', {name: /Review preview|查看预览/}).count(), 0); } finally {await remote.close();} diff --git a/apps/presentation/dashboard/smoke/workspace-navigation-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-navigation-browser-smoke.mjs new file mode 100644 index 0000000000..ba601e0614 --- /dev/null +++ b/apps/presentation/dashboard/smoke/workspace-navigation-browser-smoke.mjs @@ -0,0 +1,144 @@ +// Packaged workspace navigation with synthetic source/host descriptors. +// No model, source mutation, setting apply or session start. +import assert from 'node:assert/strict'; +import {mkdir} from 'node:fs/promises'; +import {resolve} from 'node:path'; +import {startServer, port, outputDir, packaged} from '../../../../examples/personal-workspace-browser/fixture.mjs'; +import {launchBrowser, loadPlaywright, waitForHttp} from '../../../../examples/dashboard-browser-smoke-support.mjs'; +import {openWorkspacePage} from '../../../../examples/personal-workspace-browser/scenario-context.mjs'; +import {installShowcaseWorkspace} from '../../../../examples/personal-workspace-browser/showcase-workspace.mjs'; + +const baseline = process.env.LOOPX_NAVIGATION_CAPTURE_BASE === '1'; +const stage = baseline ? 'before' : 'after'; +const server = await startServer(); +let browser; +const url = `http://127.0.0.1:${port}/${packaged ? 'chat/' : ''}?statusUrl=/status.json`; +const dialog = page => page.locator('dialog.workspace-command-palette'); +try { + await waitForHttp(url); await mkdir(outputDir, {recursive:true}); + browser = await launchBrowser(loadPlaywright().chromium); + for (const locale of ['zh-CN', 'en']) { + const session = await openWorkspacePage({newPage: options => browser.newPage({...options, hasTouch:true})}, url, {beforeGoto: async (api, page) => { + await page.addInitScript(value => localStorage.setItem('loopx-pw-locale', value), locale); + await installShowcaseWorkspace(api, page, {done:23, cost:24.8}); + const transform = api.transformStatus; + api.transformStatus = data => { + const result = transform(data) ?? data; + const goals = result.run_history.goals, original = goals.find(goal => goal.id === 'progress-projection'); + goals.push({...structuredClone(original), id:'readonly-archive', display_name:'Graph · archived experiment', + source_access:{namespace:'a'.repeat(64),label:'Graph · archived experiment',read_only:true}}); + for (let i=0; i<24; i++) goals.push({...structuredClone(original), id:`navigation-goal-${i}`, + display_name:`${i+1}. 核验长程结果与完整来源 Verify the complete export history`}); + return result; + }; + }}); + const {page} = session; + try { + await page.locator('.personal-goal-link', {hasText:'Graph · archived experiment'}).click(); + await page.getByRole('heading', {name:'Graph · archived experiment', exact:true}).waitFor(); + const launch = page.getByRole('button', {name:locale==='en'?'Quick navigation':'快速导航',exact:true}); + const settings = page.getByRole('button', {name:locale==='en'?'Settings':'设置',exact:true}); + const writes=[]; page.on('request', r => {if (!['GET','HEAD','OPTIONS'].includes(r.method())) writes.push(new URL(r.url()).pathname);}); + for (const [name,width,height] of [['desktop',1440,900],['low',1280,540],['narrow',390,640]]) { + await page.setViewportSize({width,height}); + if (width===390) await page.locator('.personal-mobile-menu').click(); + await page.locator('.personal-sidebar-nav').evaluate(e => {e.scrollTop=0;}); + await page.mouse.move(width-3,3); + await page.screenshot({path:resolve(outputDir,`navigation-${stage}-${name}-${locale}.png`),animations:'disabled'}); + if (!baseline) { + assert.equal(await settings.isVisible(),true); + const bounds=await settings.boundingBox(); assert.ok(bounds.y>=0 && bounds.y+bounds.height<=height); + const searchBounds=await launch.boundingBox(); assert.equal(searchBounds.width,44); assert.equal(searchBounds.height,44); + assert.equal(await launch.innerText(),'','no visible replacement label'); + await page.locator('.personal-sidebar-nav').evaluate(e => {e.scrollTop=e.scrollHeight;}); + assert.deepEqual(await settings.boundingBox(),bounds,'scrolling a long directory leaves settings in place'); + await settings.click(); + const panel=page.locator('.personal-settings-page'); + await panel.waitFor(); + assert.equal(await panel.getByRole('radio',{name:locale==='en'?'This device':'本机设置',exact:true}).isChecked(),true); + assert.equal(await panel.getByRole('combobox',{name:locale==='en'?'Target Goal':'目标 Goal',exact:true}).count(),0); + await panel.getByRole('radio',{name:locale==='en'?'One Goal':'Goal 设置',exact:true}).check(); + await panel.getByRole('combobox',{name:locale==='en'?'Target Goal':'目标 Goal',exact:true}).selectOption('readonly-archive'); + await panel.locator('.personal-settings-unavailable').waitFor(); + assert.equal(await panel.locator('.personal-capability-apply').count(),0); + await page.screenshot({path:resolve(outputDir,`navigation-${stage}-restriction-${name}-${locale}.png`),animations:'disabled'}); + await panel.locator('.personal-settings-back').click(); + await panel.waitFor({state:'hidden'}); + } else assert.equal(await settings.count(),0,'baseline reproduces host settings hidden by archived Goal'); + if (width===390 && await page.locator('.personal-workspace-shell.mobile-sidebar-open').count()) await page.keyboard.press('Escape'); + } + if (baseline) continue; + // Empty/partial snapshots keep their truth in contextual glyphs. Reading + // them cannot expand a parent disclosure, grant access or start work. + for (const [name,width,height] of [['desktop',1440,900],['narrow',390,844]]) { + await page.setViewportSize({width,height}); + const now = page.locator('.goal-now'); + const workHelp = now.getByRole('button',{name:locale==='en'?'Work coverage unconfirmed':'工作覆盖未确认',exact:true}); + const acceptance = page.locator('.goal-observation-details'); + const acceptanceHelp = acceptance.getByRole('button',{name:locale==='en'?'Acceptance observations unavailable':'验收观察未获取',exact:true}); + await workHelp.waitFor(); + assert.doesNotMatch(await now.innerText(), /当前快照|coverage is incomplete|没有可列出/,'No duplicate scope or empty-list paragraphs in the default view'); + assert.equal(await acceptance.evaluate(e=>e.open),false); + await page.mouse.move(width-2,2); + await page.screenshot({path:resolve(outputDir,`glyph-default-${name}-${locale}.png`),animations:'disabled'}); + await workHelp.hover(); + const bubble=page.locator('.context-help-popover:popover-open'); + await bubble.waitFor(); + assert.match(await bubble.innerText(),locale==='en'?/coverage is incomplete/:/覆盖范围不完整/); + assert.ok(await bubble.evaluate(e=>parseFloat(getComputedStyle(e).fontSize)>=14),'Optional help uses reading text'); + await page.keyboard.press('Escape'); await bubble.waitFor({state:'hidden'}); + await acceptanceHelp.focus(); await bubble.waitFor(); + assert.match(await bubble.innerText(),locale==='en'?/unknown/:/仍未知/,'Missing observations never become successful acceptance'); + await page.keyboard.press('Escape'); await bubble.waitFor({state:'hidden'}); + assert.equal(await acceptanceHelp.evaluate(e=>document.activeElement===e),true,'Escape keeps the help trigger focused'); + await acceptanceHelp.tap(); await bubble.waitFor(); + assert.equal(await acceptance.evaluate(e=>e.open),false,'Tapping help does not toggle the surrounding evidence disclosure'); + const bounds=await bubble.boundingBox(); + assert.ok(bounds.x>=0 && bounds.x+bounds.width<=width && bounds.y>=0 && bounds.y+bounds.height<=height,'Complete help fits desktop and phone'); + await page.screenshot({path:resolve(outputDir,`glyph-help-${name}-${locale}.png`),animations:'disabled'}); + await page.locator('.personal-channel-title').click(); await bubble.waitFor({state:'hidden'}); + } + await page.setViewportSize({width:1440,height:900}); + await launch.focus(); + await page.evaluate(() => { + window.navigationBackgroundClicks = 0; + document.addEventListener('click', event => {if (!event.target.closest('dialog.workspace-command-palette')) window.navigationBackgroundClicks++;}); + }); + for (const side of ['left','right','top','bottom']) { + await launch.click(); await dialog(page).waitFor(); + const count=await page.evaluate(()=>window.navigationBackgroundClicks); + const b=await dialog(page).boundingBox(); + const point={left:[b.x-5,b.y+30],right:[b.x+b.width+5,b.y+30],top:[b.x+30,b.y-5],bottom:[b.x+30,b.y+b.height+5]}[side]; + await page.mouse.click(...point); await dialog(page).waitFor({state:'hidden'}); + assert.equal(await page.evaluate(()=>window.navigationBackgroundClicks),count,'dismissal never clicks the underlying workspace'); + assert.equal(await launch.evaluate(e=>e===document.activeElement),true); + } + await launch.click(); await dialog(page).waitFor(); + const b=await dialog(page).boundingBox(); + await page.mouse.click(b.x+4,b.y+4); assert.equal(await dialog(page).isVisible(),true,'internal whitespace stays open'); + await page.mouse.move(b.x+4,b.y+4); await page.mouse.down(); await page.mouse.move(b.x-5,b.y+30); await page.mouse.up(); + assert.equal(await dialog(page).isVisible(),true,'drag from inside does not activate backdrop'); + const heading=await dialog(page).getByRole('heading').boundingBox(); + await page.mouse.move(heading.x,heading.y+10); await page.mouse.down(); + await page.mouse.move(heading.x+heading.width,heading.y+10); await page.mouse.up(); + await dialog(page).getByRole('listbox').hover(); await page.mouse.wheel(0,180); + assert.equal(await dialog(page).isVisible(),true,'text selection and list scrolling stay inside'); + const search=dialog(page).getByRole('combobox'); await search.fill('Graph'); + await search.dispatchEvent('compositionstart'); await search.dispatchEvent('keydown',{key:'Enter',keyCode:229,isComposing:true}); + assert.equal(await dialog(page).isVisible(),true); await search.dispatchEvent('compositionend'); + await search.press('Escape'); await dialog(page).waitFor({state:'hidden'}); + await launch.focus(); await page.keyboard.press('Enter'); await dialog(page).waitFor(); + await page.keyboard.press('Control+k'); await dialog(page).waitFor({state:'hidden'}); + // Touch uses the same completed outside press; the sidebar closes first. + await page.setViewportSize({width:390,height:640}); + await page.locator('.personal-mobile-menu').click(); await launch.click(); await dialog(page).waitFor(); + const mobile=await dialog(page).boundingBox(); + await page.touchscreen.tap(mobile.x-5,mobile.y+20); + await dialog(page).waitFor({state:'hidden'}); + assert.equal(await page.locator('.personal-mobile-menu').evaluate(e=>e===document.activeElement),true,'mobile dismissal returns to the visible menu'); + assert.deepEqual(writes,[],'discovery, dismissal and readonly settings never mutate or start work'); + assert.deepEqual(session.errors,[]); + } finally {await session.close();} + } + console.log(baseline?'Baseline captures reproduce missing settings.':'Workspace navigation: long directory, host/Goal isolation, desktop/low/390, EN/ZH, backdrop and focus passed.'); +} finally {await browser?.close(); server.kill();} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx index 5880eabc62..d57a288fe2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx @@ -83,8 +83,8 @@ export function AnswerReportPage({sessionId, messageId, statusUrl}: { {state.kind === "missing" ?

{zh ? "找不到这份答复。它可能已被清理,或链接有误。" : "This answer was not found. It may have been removed, or the link may be incorrect."}

: null} {answer ? <>
-