From 71cff61202f24f4216362693f7f49e8bd323b3bb Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:51:00 +0800 Subject: [PATCH 1/2] fix(app): make attention requests readable in shared details Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../attention-detail-card.tsx | 46 ++++++++++++------- .../personal-workspace/context-drawer.tsx | 16 ++----- .../src/features/personal-workspace/i18n.tsx | 10 ++-- .../personal-workspace/personal-workspace.css | 12 +++++ 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx index cf6119efe..e3c91becc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-detail-card.tsx @@ -1,4 +1,5 @@ -import { useWorkspaceI18n } from "./i18n"; +import { localizedAttentionAge, useWorkspaceI18n } from "./i18n"; +import { MarkdownText } from "./markdown"; import type { WorkspaceAttention } from "./personal-workspace-model"; export function AttentionDetailCard({ item, successor, onSelect }: { @@ -8,23 +9,34 @@ export function AttentionDetailCard({ item, successor, onSelect }: { }) { const { t } = useWorkspaceI18n(); const detail = item.details; - return
-

{t("attentionDetail.title")}

- {detail?.requestText && detail.requestText !== item.text ?

{detail.requestText}

: null} -
-
{t("attentionDetail.request")}
{t(detail?.interaction === "decision" ? "attentionDetail.decision" : "attentionDetail.unknownRequest")}
-
{t("common.status")}
{t(`attentionDetail.${detail?.lifecycle ?? "unknown"}`)}
-
{t("drawer.reason")}
{detail?.reason ?? item.explanation ?? t("attentionDetail.unknownReason")}
-
Todo
{item.todoId}
-
{t("attentionDetail.targetTodo")}
{detail?.unblocksTodoId ?? t("attentionDetail.notProvided")}
-
{t("attentionDetail.targetAgent")}
{detail?.blocksAgent ?? t("attentionDetail.notProvided")}
-
{t("attentionDetail.scope")}
{detail?.decisionScope - ? `${detail.decisionScope.kind} · ${detail.decisionScope.granularity} · ${detail.decisionScope.scopeKey}` - : t("attentionDetail.notProvided")}
-
{t("drawer.evidence")}
{detail?.evidence ?? item.evidence ?? t("drawer.decisionDefaultEvidence")}
-
- {detail?.supersededBy ?

{t("attentionDetail.replacement")}: {detail.supersededBy}

: null} + const age = localizedAttentionAge(item.updatedAt, t); + const lifecycle = detail?.lifecycle ?? "unknown"; + return
+
+ {t(`attentionDetail.${lifecycle}`)} + {item.priority ? {item.priority} : null} + {age ? {t("tasks.waitingAge", { age })} : null} +
+

{t(detail?.requestText ? "attentionDetail.request" : "attentionDetail.summary")}

+ {detail?.requestText ? : <> +

{t("attentionDetail.summaryOnly")}

+ + } +

{t("drawer.reason")}

+ +

{t("drawer.evidence")}

+ {successor && onSelect ? : null} +
+ {t("drawer.advancedDiagnostics")} +
+
Todo
{item.todoId}
+ {detail?.unblocksTodoId ?
{t("attentionDetail.targetTodo")}
{detail.unblocksTodoId}
: null} + {detail?.blocksAgent ?
{t("attentionDetail.targetAgent")}
{detail.blocksAgent}
: null} + {detail?.decisionScope ?
{t("attentionDetail.scope")}
{detail.decisionScope.kind} · {detail.decisionScope.granularity} · {detail.decisionScope.scopeKey}
: null} + {detail?.supersededBy ?
{t("attentionDetail.replacement")}
{detail.supersededBy}
: null} +
+

{t("attentionDetail.boundary")}

; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 4a9b32c4b..1a226dfd0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -38,7 +38,7 @@ import type { WorkspaceTodo, } from "./personal-workspace-model"; import type { LarkGoalConnection } from "../../data/chat"; -import { localizedAttentionAge, localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n"; +import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n"; import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue } from "./personal-workspace-model"; import { TeamPlanResult } from "./team-plan-result"; import { parseTodoResumeCondition } from "./todo-resume-condition"; @@ -57,6 +57,7 @@ const focusableSelector = [ "textarea:not([disabled])", "select:not([disabled])", "input:not([disabled])", + "summary", "[tabindex]:not([tabindex='-1'])", ].join(","); @@ -251,7 +252,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention } if (event.key === "Tab" && selection.kind !== "todo") { const focusable = Array.from(drawerRef.current?.querySelectorAll(focusableSelector) ?? []) - .filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"); + .filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true" && element.checkVisibility({ visibilityProperty: true })); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; @@ -297,7 +298,6 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention || Boolean(selection.item.latestActivity) || Boolean(selection.item.outputs?.length) ); - const attentionAge = selection.kind === "attention" ? localizedAttentionAge(selection.item.updatedAt, t) : null; const normalizedTodoResumeWhen = parseTodoResumeCondition(todoResumeWhen); async function sendCorrection() { @@ -539,16 +539,6 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.kind === "attention" ? ( <> -
- {selection.item.blocking ? t("drawer.attentionBlocking") : t("drawer.attentionWaiting")} -

{selection.item.text}

-
-
Goal
{selection.item.goalTitle ?? selection.item.goalId}
-
{t("drawer.priority")}
{selection.item.priority ?? "medium"}
- {attentionAge ?
{t("common.waiting")}
{t("tasks.waitingAge", { age: attentionAge })}
: null} - -
-
{!readOnly && canReviewAttention(selection.item) ? <> diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 8ea487836..fc352012e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -155,9 +155,9 @@ const en = { "drawer.usageNotMeasured": "Not measured", "drawer.currentGoal": "Current Goal", "attentionDetail.title": "Request details", + "attentionDetail.summary": "Available summary", + "attentionDetail.summaryOnly": "Only a summary is available. The source has not supplied the complete request.", "attentionDetail.request": "What is requested", - "attentionDetail.decision": "Decision requested", - "attentionDetail.unknownRequest": "Not specified; review the source before deciding", "attentionDetail.open": "Open in current projection", "attentionDetail.closed": "Closed", "attentionDetail.deferred": "Deferred", @@ -168,7 +168,6 @@ const en = { "attentionDetail.targetTodo": "Linked Todo to unblock", "attentionDetail.targetAgent": "Agent named by the request", "attentionDetail.scope": "Declared decision scope", - "attentionDetail.notProvided": "Not provided", "attentionDetail.replacement": "Replacement Todo", "attentionDetail.openReplacement": "Open replacement", "attentionDetail.boundary": "Reading this detail does not resolve a gate or grant authority. Available decisions require a fresh preview.", @@ -1326,9 +1325,9 @@ const zhCN: Record = { "drawer.usageNotMeasured": "未采集", "drawer.currentGoal": "当前 Goal", "attentionDetail.title": "事项说明", + "attentionDetail.summary": "现有摘要", + "attentionDetail.summaryOnly": "目前只有摘要,来源尚未提供完整请求。", "attentionDetail.request": "需要你做什么", - "attentionDetail.decision": "需要作出决定", - "attentionDetail.unknownRequest": "来源未明确,请先查看来源再判断", "attentionDetail.open": "当前投影中待处理", "attentionDetail.closed": "已关闭", "attentionDetail.deferred": "已推迟", @@ -1339,7 +1338,6 @@ const zhCN: Record = { "attentionDetail.targetTodo": "关联的待解锁 Todo", "attentionDetail.targetAgent": "事项指定的 Agent", "attentionDetail.scope": "声明的决策范围", - "attentionDetail.notProvided": "来源未提供", "attentionDetail.replacement": "替代 Todo", "attentionDetail.openReplacement": "打开替代事项", "attentionDetail.boundary": "阅读详情不会关闭 gate 或授予权限;作出决定前仍需新的操作预览。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 3eb57d6b5..b9e340fd0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1165,6 +1165,18 @@ .personal-detail-card dl div { display: grid; grid-template-columns: 74px minmax(0, 1fr); gap: 10px; font-size: 12px; } .personal-detail-card dt { color: var(--pw-faint); } .personal-detail-card dd { margin: 0; overflow-wrap: anywhere; } +.personal-attention-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; color: var(--pw-muted); font-size: 12px; } +.personal-attention-brief h3 { margin: 20px 0 12px; font-size: 16px; } +.personal-attention-brief h4 { margin: 24px 0 8px; font-size: 13px; font-weight: 600; } +.personal-attention-brief .personal-md { font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; } +.personal-attention-brief .personal-md p { color: var(--pw-text); font-size: inherit; line-height: inherit; } +.personal-attention-brief .personal-attention-missing { margin: 0 0 12px; padding: 10px 12px; border-radius: 8px; background: var(--pw-amber-bg); color: var(--pw-amber); } +.personal-attention-identifiers { margin-top: 24px; border-top: 1px solid var(--pw-line); padding-top: 16px; } +.personal-attention-identifiers summary { color: var(--pw-muted); cursor: pointer; font-size: 12px; min-height: 44px; display: flex; align-items: center; gap: 8px; list-style: none; } +.personal-attention-identifiers summary::-webkit-details-marker { display: none; } +.personal-attention-identifiers summary::before { content: "▸"; } +.personal-attention-identifiers[open] summary::before { content: "▾"; } +.personal-attention-identifiers dl { margin: 0 0 16px; } .personal-run-drawer-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; border-radius: 11px; background: #efefec; } .personal-run-drawer-tabs button { min-height: 36px; padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--pw-muted); cursor: pointer; font-size: 12px; font-weight: 650; } .personal-run-drawer-tabs button[aria-selected="true"] { background: #fff; color: var(--pw-text); box-shadow: 0 1px 4px rgb(30 28 20 / 12%); } From 6bf0c732e0864cdf4bb6a83fd1e42c8e2e75eab5 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:51:00 +0800 Subject: [PATCH 2/2] test(app): qualify readable decisions and source fences Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../app-conversation-and-async-inbox-v0.md | 17 +++- ...p-conversation-and-async-inbox-v0.zh-CN.md | 8 ++ ...hboard-attention-details-browser-smoke.mjs | 82 +++++++++++++++---- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index c8b1c20fb..67e073b90 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -74,8 +74,13 @@ Transfer the hierarchy, not the artwork or untested claims: routine activity; preserve missing authority, stale information and failures. Decision notices use the request body, object and evidence rather than a short scheduling label. Keep distinct request identities and label bounded previews; - users inspect the current request before deciding. App detail drawers retain - the request separately from the compact card label. Provider notices use the + users inspect the current request before deciding. Steward and Goal details + share one readable request brief: full body, reason/recommendation and linked + evidence first; identifiers and declared scope remain in a keyboard-accessible + disclosure. The compact list label is not repeated as another detail card. + A summary-only source is labelled as such rather than presented as a complete + request. Existing fresh-preview, read-only and lifecycle fences still govern + actions; Markdown presentation cannot infer authority. Provider notices use the same content distinction: missing request bodies are explicitly unavailable, never reconstructed from legacy action labels or free-form gate prompts. Retire obsolete presentation branches rather than preserving old data shapes @@ -95,6 +100,14 @@ return context. First-screen changes still require the repository's preview gate No external screenshot, private incident transcript or proprietary asset is redistributed by this proposal. +The decision-detail slice qualifies the packaged renderer with synthetic +desktop Chinese, narrow English and read-only sources: readable Markdown, +retained full text, evidence links, disclosure/return focus, one correctly +scoped preview, failed/missing/replaced source fences and inert source HTML. +This is a proposed App presentation improvement, not installed readback or +full GQ10 prioritization. Cross-project selection, at most two recommended +priorities and actual scoped adoption remain in the existing P1 attention work. + ## Current owners and gaps | Boundary inspected | Existing implementation | Gap to address through that owner | diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index bc1fcfe56..2a22b4e98 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -56,6 +56,9 @@ LoopX 还必须核验综合结果返回原请求。这些是文档陈述,非 - 活跃会话和结果获得最大的有效阅读空间。简洁的委派记录说明谁负责什么并链接实际工作; 返回记录指向当前结果。不要让用户到另一会话寻找已请求的答案。 - 把一个有用的下一步放在对应失败或决定旁。折叠常规活动,保留缺失授权、过期信息和失败。 + 管家与 Goal 详情复用一份可读事项说明:正文、原因/建议和证据链接优先,标识与声明范围放入键盘可达的折叠区; + 不把列表摘要重复成另一张详情卡。来源只有摘要时明确标注,不能冒充完整请求。 + 操作继续由既有新预览、只读和生命周期约束决定,Markdown 展示不能推断授权。 - 使用现有设计系统的字体、间距和克制的状态强调。动效解释已核验的转换,不虚构忙碌 worker。 - 保持创建、连接、直接与 owner 对话以及团队工作可发现。更简洁的界面不能隐藏未解决工作或减少授权范围内的 owner 发现。 @@ -63,6 +66,11 @@ LoopX 还必须核验综合结果返回原请求。这些是文档陈述,非 保留键盘访问、阅读位置和返回上下文。首屏改动仍须遵循仓库预览门禁。 本设计不重新分发外部截图、私有事故记录或专有素材。 +事项详情这一切片验证打包渲染器及合成的桌面中文、窄屏英文和只读来源:可读 Markdown、 +完整正文、证据链接、折叠区/返回焦点、一次准确作用域的预览、失败/缺失/被替代来源约束,以及不可执行的来源 HTML。 +这是待交付的 App 展示改进,不代表已安装回读或完整 GQ10 排序能力。 +跨项目选择、最多两项建议与真实的范围内采用,仍归既有 P1 注意力工作。 + ## 当前归属与缺口 | 已检查边界 | 现有实现 | 经现有 owner 解决的缺口 | diff --git a/examples/dashboard-attention-details-browser-smoke.mjs b/examples/dashboard-attention-details-browser-smoke.mjs index 2ef2978d9..8dec49b17 100644 --- a/examples/dashboard-attention-details-browser-smoke.mjs +++ b/examples/dashboard-attention-details-browser-smoke.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // Exercise the real status parser, workspace mapper and selected detail drawer. import { createRequire } from "node:module"; +import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -18,7 +19,9 @@ const server = packaged ? spawn(resolveTestPython(), ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(root, "loopx/web")], { stdio: "ignore" }) : startViteDashboardServer({ dashboardDir, port }); const url = `http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`; -const requestBody = "Review the public release evidence and the selected publication channel. ".repeat(5) + "Publish version 2.0 to stable only after acceptance."; +const requestBody = "Review **version 2.0** for the stable channel.\n\n- Publish after independent acceptance.\n- Keep the preview channel available.\n\nPublish version 2.0 to stable only after acceptance."; +const reason = "Recommend **waiting for acceptance**. Publishing now would omit the independent check; waiting keeps the preview usable."; +const evidence = "[Release checklist](https://example.org/release-checklist)\n\nIndependent acceptance is still pending."; let browser; try { await waitForHttp(url); @@ -31,6 +34,7 @@ try { page.on("pageerror", (error) => console.error(error.message)); let state = "open"; let mutationCount = 0; + let preview; const writes = []; const statusUrl = `http://127.0.0.1:${port}/${readOnly ? "remote-status" : "status"}.json`; if (readOnly) await page.addInitScript((statusUrl) => localStorage.setItem("loopx-status-source-catalog-v1", JSON.stringify({ @@ -39,6 +43,16 @@ try { await page.route("**/api/**", (route) => { if (route.request().method() !== "GET") writes.push(route.request().url()); if (route.request().method() !== "GET" && new URL(route.request().url()).pathname.startsWith("/api/actions")) mutationCount += 1; + if (new URL(route.request().url()).pathname === "/api/actions/preview") { + preview = route.request().postDataJSON(); + return route.fulfill({ status: 201, json: { ok: true, proposal: { + schema_version: "loopx_chat_action_proposal_v1", proposal_id: "proposal-decision", action_kind: preview.action_kind, + summary: preview.summary, normalized_parameters: preview.normalized_parameters, context: preview.context, + expected_state_fingerprint: "fixture-r1", permission_classification: "durable_write", validation_evidence: ["fixture validation"], + available_transitions: ["apply", "cancel"], status: "preview_ready", receipt: null, stale: null, + created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:00Z", + } } }); + } return route.fulfill({ status: 200, json: { ok: true, agents: [], sessions: [], contexts: [], connections: [], items: [] } }); }); await page.route("**/ssh-hosts*", (route) => route.fulfill({ json: { hosts: [] } })); @@ -56,25 +70,42 @@ try { const original = { index: 1, todo_id: "todo_original", role: "user", task_class: "user_gate", done: state === "superseded", status: state === "superseded" ? "done" : "open", - title: "Release review", text: requestBody, note: "A direction choice is needed before todo_target can continue.", - evidence: "review:bounded-validation", blocks_agent: "worker-one", unblocks_todo_id: "todo_target", + title: "Release review", text: requestBody, note: reason, + evidence, blocks_agent: "worker-one", unblocks_todo_id: "todo_target", decision_scope: { schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "route-one" }, ...(state === "superseded" ? { superseded_by: "todo_replacement" } : {}), }; const replacement = { ...original, index: 2, todo_id: "todo_replacement", title: "Review the replacement direction", text: "Review the replacement direction", done: false, status: "open", superseded_by: undefined }; - queue.user_todos = { items: state === "missing" ? [replacement] : state === "superseded" ? [original, replacement] : [original], total_count: 2, open_count: 1 }; + if (state === "unsafe") original.text += '\n\n\n\n[Unsafe](javascript:alert(1))'; + const unrelated = { ...replacement, todo_id: "todo_unrelated", text: "Review another step", title: "Review another step" }; + queue.user_todos = { items: state === "summary" ? [] : state === "missing" ? [unrelated] : state === "superseded" ? [original, replacement] : [original], total_count: 2, open_count: 1 }; + if (state === "summary") goal.latest_runs = [{ generated_at: "2026-08-13T01:00:00Z", goal_id: goal.id, classification: "operator_gated", + operator_gate: { operator_question: "Review candidate direction", reason_summary: "Independent acceptance is pending" } }]; if (locale === "en") queue.project_asset = { owner: "fixture-owner", gate: "pending", next_action: "Review direction", stop_condition: "Await decision", ...(queue.project_asset ?? {}), user_todos: { items: queue.user_todos.items, total: 2, open: 1 } }; return route.fulfill({ json: fixture }); }); await page.goto(readOnly ? url.replace("/status.json", encodeURIComponent(statusUrl)) : url, { waitUntil: "networkidle" }); await page.getByTestId("personal-goal-home").waitFor({ state: "visible", timeout: 10000 }).catch(async (error) => { console.error((await page.locator("body").innerText()).slice(0, 3000)); throw error; }); await page.getByTestId("personal-home-lane-needs_you").locator(".personal-home-goal-card").first().click(); - await page.locator(".personal-object-list").first().getByRole("button").first().click(); + const entry = page.locator(".personal-object-list").first().getByRole("button").first(); + await entry.click(); const drawer = page.locator(".personal-drawer-body"); - for (const value of [requestBody, "worker-one", "todo_target", "direction · action · route-one", "review:bounded-validation", "A direction choice is needed before todo_target can continue."]) { - await drawer.getByText(value, { exact: true }).waitFor({ state: "visible" }); - } await page.screenshot({ path: resolve(output, `${packaged ? "packaged" : "dev"}-${locale}${readOnly ? "-readonly" : ""}.png`), fullPage: false }); + assert.equal(await drawer.locator(".personal-detail-card").count(), 1, "One request brief replaces duplicated summary and detail cards"); + const brief = drawer.locator(".personal-attention-brief"); + await brief.getByText("Publish version 2.0 to stable only after acceptance.", { exact: true }).waitFor({ state: "visible" }); + assert.equal(await brief.locator("strong").filter({ hasText: "version 2.0" }).count(), 1); + assert.equal(await brief.locator("li").count(), 2); + assert.equal(await brief.getByRole("link", { name: "Release checklist" }).getAttribute("href"), "https://example.org/release-checklist"); + assert.ok(!(await drawer.innerText()).includes("todo_original"), "Default decision view hides technical identifiers"); + const dimensions = await drawer.evaluate(element => ({ width: element.clientWidth, content: element.scrollWidth })); + assert.ok(dimensions.content <= dimensions.width + 1, "Decision text fits the narrow drawer"); + const diagnostics = brief.locator("summary"); + await diagnostics.focus(); + await diagnostics.press("Enter"); + for (const value of ["worker-one", "todo_target", "direction · action · route-one"]) await drawer.getByText(value, { exact: true }).waitFor({ state: "visible" }); + await diagnostics.press("Enter"); + await drawer.getByText("todo_target", { exact: true }).waitFor({ state: "hidden" }); if (mutationCount) throw new Error("Reading attention details triggered a mutation"); if (readOnly) { if (await drawer.locator(".personal-primary-action").count()) throw new Error("Read-only detail exposed decision action"); @@ -82,6 +113,21 @@ try { await page.close(); continue; } if (viewport.width < 640) { await page.close(); continue; } + const more = drawer.locator(".personal-compact-menu > summary"); + await more.focus(); await more.press("Tab"); + assert.ok(await page.locator(".personal-drawer-close").evaluate(element => element === document.activeElement), "Closed menus do not strand keyboard focus"); + await drawer.getByRole("button", { name: /查看影响并决定|Review impact and decide/ }).click(); + await page.locator('[data-context-kind="proposal"]').waitFor({ state: "visible" }); + assert.equal(preview.action_kind, "gate.resolve"); + assert.equal(preview.normalized_parameters.goal_id, "loopx-meta"); + assert.equal(preview.normalized_parameters.todo_id, "todo_original"); + await page.locator(".personal-drawer-close").press("Escape"); + await entry.click(); + state = "unsafe"; + await page.locator(".personal-refresh-control .personal-icon-button").click(); + await drawer.getByText(/onerror=/).waitFor({ state: "visible" }); + assert.equal(await drawer.locator("img, script, a[href^='javascript:']").count(), 0, "Source prose cannot execute HTML or script links"); + assert.equal(await page.evaluate(() => window.decisionHtmlExecuted), undefined); state = "offline"; await page.locator(".personal-refresh-control .personal-icon-button").click(); await drawer.getByText(/来源尚未确认当前事项|The source has not confirmed this item/).waitFor({ state: "visible", timeout: 5000 }); @@ -93,19 +139,23 @@ try { await replacementButton.waitFor({ state: "visible" }); if (await drawer.locator(".personal-primary-action").count()) throw new Error("Superseded selection still exposes decision actions"); await replacementButton.click(); - await drawer.getByRole("heading", { name: "Review the replacement direction" }).waitFor({ state: "visible" }); + await drawer.getByText("Review the replacement direction", { exact: true }).waitFor({ state: "visible" }); state = "missing"; // Remove the selected identity entirely, retaining another unrelated request. - await page.unroute(`${statusUrl}*`); - await page.route(`${statusUrl}*`, (route) => { - const fixture = structuredClone(require(resolve(root, "examples/status.example.json"))); - fixture.attention_queue.items.forEach((item) => { item.user_todos = { items: [], total_count: 0, open_count: 0 }; if (item.project_asset) delete item.project_asset.user_todos; }); - return route.fulfill({ json: fixture }); - }); await page.locator(".personal-refresh-control .personal-icon-button").click(); await drawer.getByText(/来源尚未确认当前事项|The source has not confirmed this item/).waitFor({ state: "visible" }); if (await drawer.locator(".personal-primary-action").count()) throw new Error("Missing selection still exposes decision actions"); - if (mutationCount) throw new Error("Readback/successor navigation triggered a mutation"); + state = "summary"; + await page.goto(url, { waitUntil: "networkidle" }); + await page.getByTestId("personal-home-lane-needs_you").locator(".personal-home-goal-card").first().click(); + await page.locator(".personal-object-list").first().getByRole("button").first().click(); + await drawer.getByText(/目前只有摘要|Only a summary is available/).waitFor({ state: "visible" }); + assert.equal(await drawer.getByRole("heading", { name: /现有摘要|Available summary/ }).count(), 1); + await page.locator(".personal-drawer-close").press("Escape"); + await page.waitForFunction(() => !document.querySelector('[data-context-kind="attention"]')); + assert.ok(await entry.evaluate(element => element === document.activeElement), "Escape restores the originating request focus"); + assert.equal(mutationCount, 1, "Reading, disclosures, refresh and replacement cannot issue extra decisions"); + assert.equal(writes.filter(url => new URL(url).pathname.startsWith("/api/actions/") && !url.endsWith("/api/actions/preview")).length, 0, "No decision was applied"); await page.close(); } console.log(`attention-details-browser-smoke (${packaged ? "packaged" : "development"}): ok`);