diff --git a/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css b/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css index 134dc56504..a37606e34b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css @@ -10,13 +10,13 @@ .goal-report-reviews :is([data-result-catalog], [data-result-reader]):focus { outline: 2px solid var(--pw-blue-ink); outline-offset: 4px; } .report-review-tools, .report-review-tools > button, .goal-report-reviews .result-catalog-return { display: inline-flex; align-items: center; gap: 8px; } .report-review-reader { min-width: 0; margin-top: 8px; } -.report-review-masthead { padding: 8px 0; border-bottom: 1px solid var(--pw-border); } +.report-review-masthead { padding: 8px 0; border-bottom: 1px solid var(--pw-line); } .report-review-masthead h2 { margin: 8px 0; font-size: var(--text-title); line-height: var(--leading-title); overflow-wrap: anywhere; } .report-review-masthead time, .report-review-masthead span { font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); } .report-review-actions { display: flex; flex-wrap: wrap; align-items: start; gap: 8px; padding: 8px 0 16px; } .report-review-actions .personal-decision-bar { flex: 1; } .report-review-actions .personal-compact-menu { flex: 0 0 auto; } -.report-review-reader iframe { display: block; width: 100%; height: min(1000px, 80vh); min-height: 560px; border: 1px solid var(--pw-border); border-radius: 6px; background: var(--pw-surface); } +.report-review-reader iframe { display: block; width: 100%; height: min(1000px, 80vh); min-height: 560px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); } .report-review-prose { font-size: var(--text-lead); line-height: var(--leading-prose); } .report-review-reader > details { padding: 16px 0; overflow-wrap: anywhere; } @media (max-width: 640px) { .report-review-masthead > div { flex-wrap: wrap; } .report-review-reader iframe { min-height: 640px; } .report-review-actions .personal-decision-bar { flex-basis: 100%; } } diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index 2eba84897f..3410ffd3c5 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -216,7 +216,10 @@ export const conversationActivityScenario = { const answer = "检查完成,下一步已列出。"; const runtime = page.__loopxRuntime; runtime.sessions.set(next.sessionId, { ...runtime.sessions.get(next.sessionId), active_turn_id: null, status: "ready" }); - runtime.messages.get(next.sessionId).push({ message_id: `${next.turnId}-assistant`, turn_id: next.turnId, role: "assistant", text: answer }); + runtime.messages.get(next.sessionId).push({ + message_id: `${next.turnId}-assistant`, turn_id: next.turnId, role: "assistant", text: answer, + created_at: await page.evaluate(() => new Date().toISOString()), + }); response.end(event(5, "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], gate: null } })); await route.fulfill({ json: { ok: true, session_id: next.sessionId, turn_id: next.turnId, status: "completed" } }); }); @@ -246,7 +249,10 @@ export const conversationActivityScenario = { const runtime = page.__loopxRuntime; runtime.turnMessages.set(turnId, body.message); runtime.sessions.set(sessionId, {...runtime.sessions.get(sessionId), active_turn_id: turnId, status: "busy"}); - runtime.messages.get(sessionId).push({message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message}); + runtime.messages.get(sessionId).push({ + message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message, + created_at: await page.evaluate(() => new Date().toISOString()), + }); api.turnRequests.push({message: body.message, sessionId, turnId}); let holdFirstRead = true; await page.route(`**/api/chat/sessions/${sessionId}`, snapshot => { diff --git a/examples/personal-workspace-browser/goal-masthead.mjs b/examples/personal-workspace-browser/goal-masthead.mjs index 0d7bd04ff5..100471eb7e 100644 --- a/examples/personal-workspace-browser/goal-masthead.mjs +++ b/examples/personal-workspace-browser/goal-masthead.mjs @@ -245,15 +245,12 @@ export const goalMastheadScenario = { // and adapter, rather than manufacturing a complete three-item queue. const fixtureEnv = { ...process.env, LOOPX_USAGE_PING: "0" }; if (!["LOOPX_TEST_PYTHON", "LOOPX_PYTHON_BIN", "LOOPX_PYTHON"].some(key => process.env[key])) fixtureEnv.PYTHONPATH = repoRoot; - const native = JSON.parse(execFileSync(resolveTestPython(), [ - resolve(repoRoot, "examples/personal-workspace-browser/goal-attention-fixture.py"), - ], { cwd: repoRoot, env: fixtureEnv, encoding: "utf8", timeout: 30_000 })); const nativeCase = (...args) => JSON.parse(execFileSync(resolveTestPython(), [ resolve(repoRoot, "examples/personal-workspace-browser/goal-attention-fixture.py"), ...args, ], { cwd: repoRoot, env: fixtureEnv, encoding: "utf8", timeout: 30_000 })); + const native = nativeCase(); const mixed = nativeCase("--mixed"); const complete = nativeCase("--full"); - const mixedComplete = nativeCase("--mixed", "--full"); const optionalComplete = nativeCase("--optional", "--full"); // Exercise source -> parser -> adapter -> digest -> packaged rendering. // Counts are removed from a real producer payload, not a prebuilt UI model. @@ -307,6 +304,7 @@ export const goalMastheadScenario = { } finally { coverageEntries.push(...await ui.close()); } } for (const locale of ["zh-CN", "en"]) for (const mode of ["mixed", "bounded", "optional", "complete", "mixed-complete", "empty", "unknown"]) { + const partial = !["complete", "mixed-complete", "optional"].includes(mode); const ui = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: locale === "en" ? 390 : 1512, height: 982 }, @@ -317,9 +315,12 @@ export const goalMastheadScenario = { api.transformStatus = (fixture) => { previous(fixture); const queue = fixture.attention_queue.items.find(item => item.goal_id === goalId); - const source = mode === "mixed" ? mixed : mode === "complete" ? complete - : mode === "mixed-complete" ? mixedComplete : mode === "optional" ? optionalComplete : native; + const source = mode === "mixed" || mode === "mixed-complete" ? mixed : mode === "complete" ? complete + : mode === "optional" ? optionalComplete : native; Object.assign(queue, structuredClone(source)); + // Workspace prefers the owner-facing group. Bound its rows as well + // for partial cases while retaining the producer's total. + if (partial) queue.user_todos.items = queue.project_asset.user_todos.items; if (mode === "empty") { queue.user_todos.items = []; queue.project_asset.user_todos.items = []; @@ -336,13 +337,15 @@ export const goalMastheadScenario = { await openGoal(ui.page, title); const needs = await vital(ui.page, locale === "en" ? "Needs you" : "等你决定"); const expected = mode === "complete" ? "21" : mode === "mixed-complete" ? "1" : mode === "optional" ? "0" : "—"; - assert.equal(needs.value, expected, "Only complete classified rows qualify a blocking total; open User Todo totals do not classify hidden rows"); - const partial = !["complete", "mixed-complete", "optional"].includes(mode); + assert.equal(needs.value, expected, `${locale} ${mode}: only complete classified rows qualify a blocking total`); const coverageHelp = partial ? await readVitalHelp(ui.page, needs.cell) : ""; if (partial) assert.match(coverageHelp, locale === "en" ? /oldest unknown/ : /最早时间未知/); const listed = ["complete", "mixed-complete", "optional"].includes(mode) ? 21 : mode === "empty" ? 0 : 3; if (partial) assert.match(coverageHelp, new RegExp(`${listed} ${locale === "en" ? "listed" : "项"}`)); - if (mode === "optional") assert.match(needs.sub, locale === "en" ? /21 optional/ : /21 项可选/); + if (mode === "optional" || mode === "mixed-complete") { + const optional = mode === "optional" ? 21 : 20; + assert.match(needs.sub, new RegExp(`${optional} ${locale === "en" ? "optional" : "项可选"}`)); + } if (mode === "unknown") assert.doesNotMatch(needs.sub, /Nothing waiting|暂无等待/); await ui.page.getByRole("navigation", { name: /^(Goal 视图|Goal view)$/ }).getByRole("button", { name: /^(概览|Overview)$/ }).click(); const rail = ui.page.locator(".goal-overview-attention"); diff --git a/loopx/doctor.py b/loopx/doctor.py index df1d6d37ec..00d7a06c1e 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -147,12 +147,18 @@ def python_distribution_install(module_path: Path) -> dict[str, Any]: return {"available": False} installer = (installed.read_text("INSTALLER") or "").strip() or "unknown" expected = module_path.resolve() - owns_module = any( - item.as_posix().endswith("loopx/doctor.py") - and Path(item.locate()).resolve() == expected - for item in installed.files or () - ) - if not owns_module: + module_suffix = os.path.join("loopx", "doctor.py") + for item in installed.files or (): + entry = item.as_posix() + if not os.path.normcase(entry).endswith(module_suffix): + continue + # A PackagePath uses POSIX components, so locating a drive-qualified + # RECORD entry can lose its Windows root on Python 3.11. + record_path = Path(entry) + located = record_path if record_path.is_absolute() else Path(item.locate()) + if located.resolve() == expected: + break + else: return {"available": False} owner = resolve_python_install_owner(default_installer=installer, prefix=Path(sys.prefix)) return {