diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 7e125f4cbc..a7a8cd2d19 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -39,10 +39,10 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:goal-work-browse && npm run smoke:team-report && npm run smoke:answer-report && npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", - "smoke:personal-workspace-packaged:catalog": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:goal-work-browse && npm run smoke:team-report && npm run smoke:answer-report && npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", + "smoke:personal-workspace-packaged:catalog": "npm run smoke:goal-work-browse && npm run smoke:team-report && npm run smoke:answer-report && npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged:team": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs", "smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" npm run smoke:workspace-command-palette", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", @@ -67,7 +67,7 @@ "smoke:delivery-review": "tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/delivery-review src/data/delivery-review.ts src/data/goal-work-map.ts src/data/goal-acceptance-observation.ts src/features/personal-workspace/delivery-review-copy.ts && node smoke/delivery-review-smoke.mjs", "smoke:team-artifact-comparison": "tsc --noEmit && vite build --ssr smoke/team-artifact-comparison-smoke.mjs --outDir node_modules/.cache/team-comparison --emptyOutDir && node node_modules/.cache/team-comparison/team-artifact-comparison-smoke.js", "smoke:settings-target": "vite build --ssr smoke/settings-target-smoke.mjs --outDir node_modules/.cache/settings-target --emptyOutDir && node node_modules/.cache/settings-target/settings-target-smoke.js", - "smoke:team-report": "vite build --ssr smoke/team-report-smoke.mjs --outDir node_modules/.cache/team-report --emptyOutDir && node node_modules/.cache/team-report/team-report-smoke.js", + "smoke:team-report": "node --no-warnings --experimental-strip-types --test src/features/personal-workspace/markdown-blocks.test.mjs && vite build --ssr smoke/team-report-smoke.mjs --outDir node_modules/.cache/team-report --emptyOutDir && node node_modules/.cache/team-report/team-report-smoke.js", "build:chat:vite": "tsc --noEmit && vite build --config vite.chat.config.ts", "smoke:chat-upgrade": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/chat-bundle-upgrade-browser-smoke.mjs", "test:conversation-returns": "node --experimental-strip-types src/data/conversation-returns.test.mjs", @@ -77,12 +77,14 @@ "smoke:conversation-returns-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-return-browser-smoke.mjs", "smoke:workspace-scope-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/workspace-scope-browser-smoke.mjs", "smoke:recent-completions": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --types node --outDir /tmp/loopx-recent-completions-smoke smoke/recent-completions-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-recent-completions-smoke/apps/presentation/dashboard/smoke/recent-completions-smoke.js", - "smoke:task-inspector-route": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/task-inspector-route-smoke.mjs", + "smoke:task-inspector-route": "npm run smoke:goal-work-browse && 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 && node --experimental-strip-types smoke/workspace-navigation-browser-smoke.mjs", "smoke:app-steward": "vite build --ssr smoke/app-steward-smoke.ts --outDir node_modules/.cache/loopx-app-steward --emptyOutDir && node node_modules/.cache/loopx-app-steward/app-steward-smoke.js", - "smoke:conversation-input": "node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && vite build --ssr smoke/conversation-input-smoke.ts --outDir node_modules/.cache/loopx-conversation-input --emptyOutDir && node node_modules/.cache/loopx-conversation-input/conversation-input-smoke.js" + "smoke:conversation-input": "node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && vite build --ssr smoke/conversation-input-smoke.ts --outDir node_modules/.cache/loopx-conversation-input --emptyOutDir && node node_modules/.cache/loopx-conversation-input/conversation-input-smoke.js", + "smoke:answer-report": "vite build --ssr smoke/answer-report-smoke.tsx --outDir node_modules/.cache/loopx-answer-report --emptyOutDir && node node_modules/.cache/loopx-answer-report/answer-report-smoke.js", + "smoke:goal-work-browse": "vite build --ssr src/features/personal-workspace/goal-work-browse.test.mjs --outDir node_modules/.cache/loopx-work-browse --emptyOutDir && node node_modules/.cache/loopx-work-browse/goal-work-browse.test.js" }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/presentation/dashboard/smoke/answer-report-smoke.tsx b/apps/presentation/dashboard/smoke/answer-report-smoke.tsx index 0d0178ae5f..22db19fbe7 100644 --- a/apps/presentation/dashboard/smoke/answer-report-smoke.tsx +++ b/apps/presentation/dashboard/smoke/answer-report-smoke.tsx @@ -21,6 +21,9 @@ for (const zh of [false, true]) { assert.equal(url.searchParams.get("statusUrl"), "/selected/status.json"); assert.equal(url.searchParams.has("reportSessionId"), false); assert.equal(url.searchParams.has("reportMessageId"), false); + assert.equal(url.searchParams.get("messageSession"), ["manager", "goal.community"].includes(channel_id) ? "old" : null); + assert.equal(url.searchParams.get("message"), ["manager", "goal.community"].includes(channel_id) ? "answer" : null, + "Only a known original conversation receives a message focus"); } for (const session of [null, {goal_id: "community"}, {goal_id: "loopx-manager", channel_id: "goal.loopx-manager"}]) { assert.equal(new URL(answerReportNavigation(session, current, "", zh).url).searchParams.get("goalId"), ""); @@ -49,4 +52,30 @@ for (const [status, verification, expected] of [ assert.ok(html.includes(expected)); assert.ok(html.includes("Progress update")); } +const original = `${current}&conversationBinding=${"a".repeat(24)}&conversationSession=bound&workAgent=wrong&workSession=wrong&todo=wrong`; +const bound = new URL(answerReportNavigation({session_id: "bound", goal_id: "loopx-manager", channel_id: "manager.external.synthetic"}, original, "/source/status.json", false).url); +assert.equal(bound.searchParams.get("conversationBinding"), "a".repeat(24)); +assert.equal(bound.searchParams.get("conversationSession"), "bound"); +assert.equal(bound.searchParams.get("messageSession"), "bound"); +assert.equal(bound.searchParams.get("message"), "answer"); +assert.equal(bound.searchParams.get("todo"), null); +assert.equal(bound.searchParams.get("workAgent"), null); +const wrongBound = new URL(answerReportNavigation({session_id: "other", goal_id: "loopx-manager", channel_id: "manager.external.synthetic"}, original, "", false).url); +assert.equal(wrongBound.searchParams.get("conversationBinding"), null, "An unrelated Session never inherits a bound source"); +assert.equal(wrongBound.searchParams.get("message"), null); +const managed = new URL(answerReportNavigation({session_id: "work", goal_id: "community", channel_id: "agent.writer", managed_agent_id: "writer", session_mode: "managed_runtime"}, original, "", false).url); +assert.equal(managed.searchParams.get("goalId"), "community"); +assert.equal(managed.searchParams.get("workAgent"), "writer"); +assert.equal(managed.searchParams.get("workSession"), "managed:work"); +assert.equal(managed.searchParams.get("messageSession"), "work"); +assert.equal(managed.searchParams.get("conversationBinding"), null); +const projectRef = "b".repeat(24); +const project = new URL(answerReportNavigation({session_id: "project", goal_id: null, project_ref: projectRef, channel_id: `project.${projectRef}`}, original, "", false).url); +assert.equal(project.searchParams.get("workspace"), projectRef); +assert.equal(project.searchParams.get("goalId"), ""); +assert.equal(project.searchParams.get("messageSession"), "project"); +assert.equal(project.searchParams.get("conversationBinding"), null); +const externalProject = new URL(answerReportNavigation({session_id: "bound", goal_id: null, project_ref: projectRef, channel_id: `project.external.${"a".repeat(24)}.${"c".repeat(24)}`}, original, "", false).url); +assert.equal(externalProject.searchParams.get("conversationBinding"), "a".repeat(24)); +assert.equal(externalProject.searchParams.get("workspace"), null, "An external project retains its original audience binding"); console.log("answer-report: scope isolation, actual phase, delivery uncertainty and legacy answers passed"); diff --git a/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs index bdacc81b26..b81f6489ce 100644 --- a/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs @@ -27,7 +27,10 @@ try { const state = { done: 23, cost: 24.8, deliveryFails: false }; const session = await openWorkspacePage({ newPage: options => browser.newPage({ locale: 'zh-CN', ...options }) }, url, { viewport: { width: 1440, height: 1000 }, + gotoWaitUntil: 'domcontentloaded', beforeGoto: async (api, page) => { + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(15000); await installShowcaseWorkspace(api, page, state); const transform = api.transformStatus; api.transformStatus = fixture => { @@ -82,8 +85,17 @@ try { await inlineRail.locator('[data-testid="completed-task-lane"] > header button').click(); await historyResponse; const overview = page.locator('.goal-overview'); + await decision.focus(); await overview.evaluate(element => element.style.maxWidth = '800px'); await page.waitForFunction(() => !document.querySelector('.goal-workspace-rail .goal-all-work')); + await page.waitForFunction(() => document.querySelector('.goal-workspace-composition')?.classList.contains('is-attention-first')); + assert.equal(await decision.count(), 1, 'A narrow blocking decision remains one interactive instance'); + assert.equal(await decision.evaluate(element => element === document.activeElement), true, 'Container reflow preserves the focused decision'); + assert.ok(await decision.evaluate(element => { + const direction = document.querySelector('.goal-overview-direction'); + return Boolean(element.compareDocumentPosition(direction) & Node.DOCUMENT_POSITION_FOLLOWING) + && element.getBoundingClientRect().top < direction.getBoundingClientRect().top; + }), 'The blocking decision precedes the long Goal in DOM and visual order'); assert.equal(await inventory.isVisible(), false, 'Container reflow hides the inline inventory'); const readsBeforeHiddenRefresh = historyReads; state.done++; diff --git a/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs b/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs index 49a8be8cdd..e3d22ac39c 100644 --- a/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs +++ b/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs @@ -22,7 +22,10 @@ try { browser = await launchBrowser(loadPlaywright().chromium); await mkdir(outputDir, { recursive: true }); for (const { height, width } of viewports) { - const workspace = await openWorkspacePage({ newPage: options => browser.newPage({ locale: "zh-CN", ...options }) }, url, { viewport: { width, height } }); + const workspace = await openWorkspacePage({ newPage: options => browser.newPage({ locale: "zh-CN", ...options }) }, url, { + viewport: { width, height }, gotoWaitUntil: "domcontentloaded", + beforeGoto: (_api, page) => {page.setDefaultTimeout(10000); page.setDefaultNavigationTimeout(15000);}, + }); const { page, api } = workspace; try { await page.emulateMedia({ reducedMotion: "reduce" }); @@ -33,15 +36,15 @@ try { await activeTab.filter({ hasText: "概览" }).waitFor(); const goalLink = new URL(page.url()); goalLink.searchParams.delete("view"); - await page.goto(goalLink.href, { waitUntil: "networkidle" }); + await page.goto(goalLink.href, { waitUntil: "domcontentloaded" }); await activeTab.filter({ hasText: "概览" }).waitFor(); - await page.reload({ waitUntil: "networkidle" }); + await page.reload({ waitUntil: "domcontentloaded" }); await activeTab.filter({ hasText: "概览" }).waitFor(); await page.screenshot({ path: resolve(outputDir, `goal-default-overview-${width}.png`), animations: "disabled" }); for (const [view, label] of [["conversation", "对话"], ["files", "成果"], ["tasks", "任务"]]) { const explicitLink = new URL(goalLink); explicitLink.searchParams.set("view", view); - await page.goto(explicitLink.href, { waitUntil: "networkidle" }); + await page.goto(explicitLink.href, { waitUntil: "domcontentloaded" }); await activeTab.filter({ hasText: label }).waitFor(); } const opener = page.locator(".personal-task-card", { hasText: "Current Todo" }).locator(":scope > button"); @@ -170,7 +173,7 @@ try { && getComputedStyle(document.documentElement).overflow !== "hidden" && getComputedStyle(document.body).overflow !== "hidden"; }); - await page.reload({ waitUntil: "networkidle" }); + await page.reload({ waitUntil: "domcontentloaded" }); await opener.waitFor(); assert.equal(await drawer.count(), 0, "Reload cannot resurrect a dismissed Task"); assert.deepEqual(scope(), initialScope); @@ -187,26 +190,76 @@ try { await page.goForward(); await page.waitForURL(value => !value.searchParams.has("todo")); await drawer.waitFor({ state: "hidden" }); - await page.goto(deepLink, { waitUntil: "networkidle" }); + await page.goto(deepLink, { waitUntil: "domcontentloaded" }); await drawer.waitFor(); - await page.reload({ waitUntil: "networkidle" }); + await page.reload({ waitUntil: "domcontentloaded" }); await drawer.waitFor(); assert.equal(new URL(page.url()).searchParams.get("todo"), "todo-progress-current", "A deliberate deep link remains restorable"); await drawer.getByRole("button", { name: /关闭详情/ }).click(); await page.waitForURL(value => !value.searchParams.has("todo")); - await page.reload({ waitUntil: "networkidle" }); + await page.reload({ waitUntil: "domcontentloaded" }); await opener.waitFor(); assert.equal(await drawer.count(), 0); const unavailable = new URL(deepLink); unavailable.searchParams.set("todo", "todo-missing-route"); - await page.goto(unavailable.href, { waitUntil: "networkidle" }); + await page.goto(unavailable.href, { waitUntil: "domcontentloaded" }); await drawer.getByRole("button", { name: "重试读取完整要求" }).waitFor(); await page.keyboard.press("Escape"); await page.waitForURL(value => !value.searchParams.has("todo")); - await page.reload({ waitUntil: "networkidle" }); + await page.reload({ waitUntil: "domcontentloaded" }); await opener.waitFor(); assert.equal(await drawer.count(), 0, "An unavailable deep link can be dismissed without a recovery loop"); assert.deepEqual(scope(), initialScope); + if (width === 1512 || width === 390) { + const tasks = page.locator('[data-goal-panel="tasks"] .personal-task-board'); + const taskSearch = tasks.getByRole("searchbox", {name: "搜索当前已列出的任务"}); + await tasks.getByRole("button", {name: "列表", exact: true}).click(); + await taskSearch.fill("Current"); + await tasks.getByRole("combobox", {name: "按工作 Agent 筛选"}).selectOption(""); + await page.waitForURL(value => value.searchParams.get("taskView") === "list" && value.searchParams.get("taskOwner") === ""); + await page.locator('.personal-goal-tabs button').filter({hasText: "概览"}).click(); + const now = page.getByRole("region", {name: "当前工作", exact: true}); + await now.getByRole("button", {name: "看板", exact: true}).click(); + const overviewBoard = page.locator('.goal-overview .personal-task-board'); + assert.equal(await overviewBoard.getByRole("searchbox", {name: "搜索当前已列出的任务"}).inputValue(), "Current", "Overview and Tasks share the listed-task query"); + assert.equal(await overviewBoard.getByRole("button", {name: "列表", exact: true}).getAttribute("aria-pressed"), "true"); + await page.reload({waitUntil: "domcontentloaded"}); + await overviewBoard.waitFor(); + assert.equal(await overviewBoard.getByRole("searchbox", {name: "搜索当前已列出的任务"}).inputValue(), "Current", "Reload restores the scoped query"); + assert.equal(await overviewBoard.getByRole("combobox", {name: "按工作 Agent 筛选"}).inputValue(), "", "Unassigned remains an explicit filter"); + await page.goBack(); + await now.getByRole("button", {name: "按负责人", exact: true}).waitFor(); + assert.equal(await now.getByRole("button", {name: "按负责人", exact: true}).getAttribute("aria-pressed"), "true", "Back restores the overview lens"); + await page.goForward(); + await overviewBoard.waitFor(); + await page.locator('.personal-goal-tabs button').filter({hasText: "任务"}).click(); + await tasks.waitFor(); + if (width === 1512) { + await tasks.locator('.personal-task-card', {hasText: "Current Todo"}).locator(":scope > button").click(); + await drawer.waitFor(); + await tasks.getByRole("group", {name: "任务状态筛选"}).getByRole("button", {name: "受阻与延后", exact: true}).click(); + await tasks.locator(".personal-task-selection-note").waitFor(); + assert.equal(new URL(page.url()).searchParams.get("todo"), "todo-progress-current", "Filtering cannot change the selected Task identity"); + await tasks.getByRole("button", {name: "显示所选任务", exact: true}).click(); + await drawer.getByRole("button", {name: /关闭详情/}).click(); + await drawer.waitFor({state: "hidden"}); + } else { + await tasks.getByRole("button", {name: "清除筛选", exact: true}).click(); + } + await tasks.getByRole("button", {name: "看板", exact: true}).click(); + const otherGoal = new URL(page.url()); + otherGoal.searchParams.set("goalId", "product-release"); + // A copied old filter address must not filter another Goal. + otherGoal.searchParams.set("taskQuery", "Current"); + otherGoal.searchParams.set("taskView", "list"); + await page.goto(otherGoal.href, {waitUntil: "domcontentloaded"}); + const otherTasks = page.locator('[data-goal-panel="tasks"] .personal-task-board'); + await otherTasks.waitFor(); + assert.equal(await otherTasks.getByRole("searchbox", {name: "搜索当前已列出的任务"}).inputValue(), ""); + assert.equal(await otherTasks.getByRole("button", {name: "看板", exact: true}).getAttribute("aria-pressed"), "true", "Another Goal does not inherit route presentation state"); + await page.goto(goalLink.href + (goalLink.search ? "&" : "?") + "view=tasks", {waitUntil: "domcontentloaded"}); + await opener.waitFor(); + } if (width === 1512) { await opener.click(); await drawer.waitFor(); @@ -237,7 +290,7 @@ try { await drawer.waitFor({ state: "hidden" }); await page.waitForFunction(() => document.activeElement?.classList.contains("personal-task-board")); assert.equal( - await page.locator(".personal-task-board").evaluate(element => document.activeElement === element), + await page.locator('[data-goal-panel="tasks"]:not([hidden]) .personal-task-board').evaluate(element => document.activeElement === element), true, "A removed Task opener falls back to the Task list instead of another Task", ); diff --git a/apps/presentation/dashboard/smoke/team-report-smoke.mjs b/apps/presentation/dashboard/smoke/team-report-smoke.mjs index 12ab182742..412daf425c 100644 --- a/apps/presentation/dashboard/smoke/team-report-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-report-smoke.mjs @@ -15,6 +15,9 @@ const raw = renderToStaticMarkup(createElement(TeamArtifactContent, {artifact, l assert.doesNotMatch(raw, /|/, "A conversation renders the same safe report table as an artifact"); +assert.match(chat, /]*class="personal-md-heading is-h1"[^>]*>Research<\/h2>/, + "A document heading is navigable semantic HTML beneath the page title"); +assert.doesNotMatch(chat, / div { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12px; } .answer-report-brand { font-size: var(--text-heading); line-height: var(--leading-heading); font-weight: 600; letter-spacing: -.04em; } -.answer-report-context { color: #777; font-size: var(--text-body); line-height: var(--leading-body); overflow-wrap: anywhere; } -.answer-report-header button, .answer-report-heading button { min-height: 44px; border: 1px solid #d7d7d7; border-radius: 8px; background: #fff; color: #171717; padding: 8px 14px; font: inherit; font-size: var(--text-body); cursor: pointer; } -.answer-report-header button:hover, .answer-report-heading button:hover { border-color: #8f8f8f; } -.answer-report-page button:focus-visible, .answer-report-page summary:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; } -.answer-report-body { padding: 48px min(5vw, 56px) 56px; margin: 28px 0 0; background: #fff; border: 1px solid #ebebeb; border-radius: 14px; min-width: 0; } -.answer-report-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid #ebebeb; padding-bottom: 24px; margin-bottom: 28px; } -.answer-report-heading small { color: #666; font-size: var(--text-caption); line-height: var(--leading-caption); } +.answer-report-context { color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); overflow-wrap: anywhere; } +.answer-report-header button, .answer-report-heading button { min-height: 44px; border: 1px solid var(--pw-line-strong); border-radius: 8px; background: var(--pw-card); color: var(--pw-text); padding: 8px 14px; font: inherit; font-size: var(--text-body); cursor: pointer; } +.answer-report-header button:hover, .answer-report-heading button:hover { border-color: var(--pw-faint); } +.answer-report-page button:focus-visible, .answer-report-page summary:focus-visible, .answer-report-page a:focus-visible, .answer-report-page textarea:focus-visible { outline: 2px solid var(--pw-blue-ink); outline-offset: 3px; } +.answer-report-body { padding: 48px min(5vw, 56px) 56px; margin: 28px 0 0; background: var(--pw-card); border: 1px solid var(--pw-line); border-radius: 14px; min-width: 0; } +.answer-report-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid var(--pw-line); padding-bottom: 24px; margin-bottom: 28px; } +.answer-report-heading small { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } .answer-report-heading h1 { font-size: clamp(var(--text-title), 3vw, var(--text-display)); font-weight: 600; letter-spacing: -.035em; line-height: 1.25; margin: 6px 0 8px; } -.answer-report-heading p { color: #777; margin: 0; font-size: var(--text-caption); line-height: var(--leading-caption); } +.answer-report-heading p { color: var(--pw-muted); margin: 0; font-size: var(--text-caption); line-height: var(--leading-caption); } .answer-report-content { min-width: 0; overflow-wrap: anywhere; } -.answer-report-content .personal-md { display: grid; gap: 18px; font-size: var(--text-lead); line-height: var(--leading-prose); } +.answer-report-page .answer-report-content .personal-md { display: grid; gap: 18px; font-size: var(--text-lead); line-height: var(--leading-prose); } .answer-report-content .personal-md p { margin: 0; font-size: inherit; line-height: inherit; } .answer-report-content .personal-md-heading.is-h1 { font-size: var(--text-title); line-height: var(--leading-prose); font-weight: 600; } .answer-report-content .personal-md-heading.is-h2 { font-size: var(--text-heading); line-height: var(--leading-prose); font-weight: 600; } .answer-report-content .personal-md-table-scroll { max-width: 100%; overflow-x: auto; } -.answer-report-body footer { border-top: 1px solid #ebebeb; margin-top: 40px; padding-top: 16px; color: #666; font-size: var(--text-caption); line-height: var(--leading-caption); } +.answer-report-body footer { border-top: 1px solid var(--pw-line); margin-top: 40px; padding-top: 16px; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-prose); } .answer-report-body footer summary { cursor: pointer; min-height: 36px; align-content: center; } .answer-report-body footer dl { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; } .answer-report-body footer dd { margin: 0; overflow-wrap: anywhere; } -.answer-report-body footer code { font-size: var(--text-caption); line-height: var(--leading-caption); } -.personal-answer-link { display: inline-block; margin-top: 8px; font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-blue-ink, #0070f3); text-decoration: none; } +.answer-report-body footer code { font-size: var(--text-body); line-height: var(--leading-body); } +.personal-answer-link { display: inline-block; margin-top: 8px; font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-blue-ink); text-decoration: none; } .personal-answer-link:hover { text-decoration: underline; } -.personal-answer-link:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; } -@media (max-width: 640px) { .answer-report-page { padding: 0 12px 28px; } .answer-report-header { min-height: 68px; } .answer-report-body { margin-top: 12px; padding: 28px 16px 36px; } } +.personal-answer-link:focus-visible { outline: 2px solid var(--pw-blue-ink); outline-offset: 3px; } +@media (max-width: 640px) { .answer-report-page.personal-workspace-shell { padding: 0 12px 28px; } .answer-report-header { min-height: 68px; } .answer-report-body { margin-top: 12px; padding: 28px 16px 36px; } } + +.answer-report-outline { margin-bottom: 28px; border-bottom: 1px solid var(--pw-line); padding-bottom: 16px; } +.answer-report-outline > summary { cursor: pointer; min-height: 44px; align-content: center; font-weight: 500; } +.answer-report-outline ol { display: grid; gap: 4px; margin: 8px 0 0; padding: 0; list-style: none; } +.answer-report-outline li { min-width: 0; overflow-wrap: anywhere; } +.answer-report-outline li[data-level="3"] { padding-inline-start: 16px; } +.answer-report-outline li[data-level="4"], .answer-report-outline li[data-level="5"], .answer-report-outline li[data-level="6"] { padding-inline-start: 32px; } +.answer-report-outline a { display: inline-block; padding-block: 8px; color: var(--pw-blue-ink); text-decoration: underline; text-underline-offset: 3px; } +.answer-report-copy-fallback { margin-bottom: 24px; } +.answer-report-copy-fallback textarea { display: block; width: 100%; resize: vertical; padding: 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: var(--text-body) / var(--leading-prose) var(--font-mono); } 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 d57a288fe2..e0ef26a4e9 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 @@ -1,4 +1,4 @@ -import {useEffect, useState} from "react"; +import {useEffect, useMemo, useRef, useState} from "react"; import {fetchChatSession, type ChatSessionSnapshot, type ChatVisibleMessage} from "../../data/chat"; import {visibleAgentMessage} from "./answer-text"; @@ -6,6 +6,8 @@ import {readWorkspaceLocale} from "./i18n"; import {resolveConversationScope} from "../../../../../../loopx/control_plane/collaboration/conversation_scope"; import {ReturnDeliveryStatus} from "./return-delivery-status"; import {MarkdownText} from "./markdown"; +import {markdownOutline, parseMarkdownBlocks} from "./markdown-blocks"; +import {readWorkspaceTheme} from "./workspace-theme"; import "./personal-workspace.css"; import "./goal-loopx-mode.css"; import "./answer-report-page.css"; @@ -13,7 +15,7 @@ import "./answer-report-page.css"; type ReportState = | {kind: "loading"} | {kind: "ready"; snapshot: ChatSessionSnapshot; message: ChatVisibleMessage} - | {kind: "missing"} + | {kind: "missing"; snapshot: ChatSessionSnapshot} | {kind: "error"}; export function AnswerReportPage({sessionId, messageId, statusUrl}: { @@ -21,27 +23,39 @@ export function AnswerReportPage({sessionId, messageId, statusUrl}: { messageId: string; statusUrl: string; }) { - const [state, setState] = useState({kind: "loading"}); + const scope = JSON.stringify([statusUrl || "/status.json", sessionId, messageId]); + const [loaded, setLoaded] = useState<{scope: string; value: ReportState} | null>(null); + // A late read from the preceding source never appears in the new reader. + const state: ReportState = loaded?.scope === scope ? loaded.value : {kind: "loading"}; + const [readRevision, setReadRevision] = useState(0); + const manualCopy = useRef(null); + const copyAttempt = useRef(0); const [copied, setCopied] = useState(false); const [copyFailed, setCopyFailed] = useState(false); const zh = readWorkspaceLocale() === "zh-CN"; useEffect(() => { let live = true; + const controller = new AbortController(); + copyAttempt.current += 1; const previousTitle = document.title; document.title = zh ? "答复记录 · LoopX" : "Answer record · LoopX"; - setState({kind: "loading"}); - void fetchChatSession(sessionId).then((snapshot) => { + setLoaded({scope, value: {kind: "loading"}}); + setCopied(false); + setCopyFailed(false); + void fetchChatSession(sessionId, controller.signal).then((snapshot) => { if (!live) return; + if (snapshot.ok !== true || snapshot.schema_version !== "loopx_chat_store_v1" + || snapshot.session.session_id !== sessionId) throw new Error("The saved Session changed during read."); const message = snapshot.messages.find((item) => item.message_id === messageId && ["agent", "assistant"].includes(item.role)); - setState(message ? {kind: "ready", snapshot, message} : {kind: "missing"}); - }).catch(() => { if (live) setState({kind: "error"}); }); - return () => { live = false; document.title = previousTitle; }; - }, [sessionId, messageId, zh]); + setLoaded({scope, value: message ? {kind: "ready", snapshot, message} : {kind: "missing", snapshot}}); + }).catch(() => { if (live) setLoaded({scope, value: {kind: "error"}}); }); + return () => {live = false; controller.abort(); copyAttempt.current += 1; document.title = previousTitle;}; + }, [scope, sessionId, messageId, zh, readRevision]); const navigation = answerReportNavigation( - state.kind === "ready" ? state.snapshot.session : null, window.location.href, statusUrl, zh, + (state.kind === "ready" || state.kind === "missing") ? state.snapshot.session : null, window.location.href, statusUrl, zh, ); const closeOrReturn = () => { if (window.opener) window.close(); @@ -50,7 +64,36 @@ export function AnswerReportPage({sessionId, messageId, statusUrl}: { const answer = state.kind === "ready" ? state.message : null; const answerText = answer ? visibleAgentMessage(answer.text) : ""; - const sourceQuestion = state.kind === "ready" + const headingPrefix = `answer-${sessionId}-${messageId}`; + const headings = useMemo(() => markdownOutline(parseMarkdownBlocks(answerText), headingPrefix), [answerText, headingPrefix]); + useEffect(() => { + if (!copyFailed) return; + manualCopy.current?.focus(); manualCopy.current?.select(); + }, [copyFailed]); + useEffect(() => { + if (state.kind !== "ready") return; + let frame = window.requestAnimationFrame(() => { + frame = window.requestAnimationFrame(() => { + const anchor = headings.find(heading => `#${encodeURIComponent(heading.id)}` === window.location.hash + || `#${heading.id}` === window.location.hash); + if (anchor) document.getElementById(anchor.id)?.scrollIntoView({block: "start"}); + else window.scrollTo({top: readAnswerPosition(scope), behavior: "instant"}); + }); + }); + const save = () => saveAnswerPosition(scope, window.scrollY); + window.addEventListener("pagehide", save); + return () => {window.cancelAnimationFrame(frame); window.removeEventListener("pagehide", save); save();}; + }, [scope, state.kind, headings]); + async function copyAnswer() { + const attempt = ++copyAttempt.current; + setCopyFailed(false); + try { + if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable"); + await navigator.clipboard.writeText(answerText); + if (attempt === copyAttempt.current) setCopied(true); + } catch {if (attempt === copyAttempt.current) {setCopied(false); setCopyFailed(true);}} + } + const sourceQuestion = state.kind === "ready" && answer?.turn_id ? state.snapshot.messages.find((item) => item.turn_id === answer?.turn_id && item.role === "user") : null; const created = answer?.created_at ? new Date(answer.created_at) : null; @@ -58,7 +101,7 @@ export function AnswerReportPage({sessionId, messageId, statusUrl}: { ? new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {dateStyle: "medium", timeStyle: "short"}).format(created) : null; - return
+ return
LoopX @@ -70,24 +113,23 @@ export function AnswerReportPage({sessionId, messageId, statusUrl}: {
{zh ? "已保存的答复" : "Saved answer"} {dateLabel ?

: null} -
{answer ? : null}
- {copyFailed ?

{zh ? "复制失败。请从正文中选中并复制。" : "Copy failed. Select and copy the answer text instead."}

: null} +
{answer ? : null}
+ {copyFailed ?

{zh ? "复制失败。可从下面的 Markdown 原文中选中并复制。" : "Copy failed. Select and copy the Markdown source below."}

+