From 3d315dd36fa3273a99c3e267e79cd8f6a3182097 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 27 Oct 2026 00:30:23 +0800 Subject: [PATCH 1/2] fix(ui): align mobile task inspector modality and focus Signed-off-by: song --- .../smoke/task-inspector-route-smoke.mjs | 125 +++++++++++++++++- .../personal-workspace/context-drawer.tsx | 43 +++++- .../personal-workspace/goal-tasks-view.tsx | 2 +- .../personal-workspace-contract.test.mjs | 5 +- .../personal-workspace/workspace-shell.tsx | 49 ++++++- 5 files changed, 210 insertions(+), 14 deletions(-) diff --git a/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs b/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs index 9e20ad4457..afb6692dce 100644 --- a/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs +++ b/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs @@ -7,6 +7,13 @@ import { launchBrowser, loadPlaywright, waitForHttp } from "../../../../examples process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; const { outputDir, port, startServer } = await import("../../../../examples/personal-workspace-browser/fixture.mjs"); const { openWorkspacePage } = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const viewports = [ + { height: 844, width: 1512 }, + { height: 768, width: 1024 }, + { height: 1024, width: 768 }, + { height: 844, width: 390 }, + { height: 844, width: 320 }, +]; let server, browser; try { server = await startServer(); @@ -14,8 +21,8 @@ try { await waitForHttp(url); browser = await launchBrowser(loadPlaywright().chromium); await mkdir(outputDir, { recursive: true }); - for (const width of [1512, 390]) { - const workspace = await openWorkspacePage({ newPage: options => browser.newPage({ locale: "zh-CN", ...options }) }, url, { viewport: { width, height: 844 } }); + for (const { height, width } of viewports) { + const workspace = await openWorkspacePage({ newPage: options => browser.newPage({ locale: "zh-CN", ...options }) }, url, { viewport: { width, height } }); const { page, api } = workspace; try { await page.emulateMedia({ reducedMotion: "reduce" }); @@ -39,10 +46,25 @@ try { } const opener = page.locator(".personal-task-card", { hasText: "Current Todo" }).locator(":scope > button"); const drawer = page.getByRole("dialog", { name: "Todo 详情" }); + const overlayExpected = width <= 1050; const scope = () => { const search = new URL(page.url()).searchParams; return [search.get("goalId"), search.get("statusUrl"), search.get("view")]; }; + const assertModality = async (modal) => { + await page.waitForFunction(({ modal }) => { + const contextDrawer = document.querySelector('[data-context-drawer]'); + const contextDialog = document.querySelector('.personal-context-drawer[data-context-kind="todo"]'); + const workspaceMain = document.querySelector(".personal-workspace-main"); + if (!contextDrawer || !contextDialog || !workspaceMain) return false; + return (contextDialog.getAttribute("aria-modal") === "true") === modal + && (workspaceMain.getAttribute("aria-hidden") === "true") === modal + && workspaceMain.inert === modal + && (getComputedStyle(contextDrawer).position === "fixed") === modal + && (getComputedStyle(document.documentElement).overflow === "hidden") === modal + && (getComputedStyle(document.body).overflow === "hidden") === modal; + }, { modal }); + }; await opener.waitFor(); const initialScope = scope(); await opener.focus(); @@ -50,12 +72,96 @@ try { await drawer.waitFor(); await page.waitForURL(value => value.searchParams.get("todo") === "todo-progress-current"); const deepLink = page.url(); + await assertModality(overlayExpected); + const backgroundControl = page.locator('.personal-goal-tabs button[aria-current="page"]'); + assert.equal( + await backgroundControl.evaluate(element => { + element.focus(); + return document.activeElement === element; + }), + !overlayExpected, + overlayExpected + ? "A narrow Task inspector must make the background unavailable to focus" + : "A wide Task inspector remains non-modal", + ); + if (!overlayExpected) { + await page.setViewportSize({ height: 844, width: 1024 }); + await assertModality(true); + await page.waitForFunction(() => document.querySelector(".personal-context-drawer")?.contains(document.activeElement)); + await page.setViewportSize({ height, width }); + await assertModality(false); + } + if (overlayExpected) { + await drawer.locator("#personal-drawer-title").focus(); + for (const key of ["Shift+Tab", "Tab", "Tab", "Shift+Tab"]) { + await page.keyboard.press(key); + assert.equal( + await drawer.evaluate(element => element.contains(document.activeElement)), + true, + `${key} must remain inside the narrow Task inspector`, + ); + } + } + await page.screenshot({ path: resolve(outputDir, `task-inspector-modal-${width}.png`), animations: "disabled" }); + + const management = drawer.locator(".personal-task-management"); + await management.locator(":scope > summary").click(); + assert.notEqual(await management.getAttribute("open"), null, "Manage task opens as the nested top layer"); + if (width === 390) { + await page.screenshot({ path: resolve(outputDir, "task-inspector-menu-390.png"), animations: "disabled" }); + } + await page.keyboard.press("Escape"); + await drawer.waitFor(); + assert.equal(await management.getAttribute("open"), null, "Escape closes the nested menu before the inspector"); + assert.equal( + await management.locator(":scope > summary").evaluate(element => document.activeElement === element), + true, + "Closing the nested menu restores its summary focus", + ); + if (!overlayExpected) { + await page.evaluate(() => { + const dialog = document.createElement("dialog"); + dialog.dataset.taskInspectorTopLayer = "true"; + const button = document.createElement("button"); + button.textContent = "Nested dialog"; + dialog.append(button); + document.body.append(dialog); + dialog.showModal(); + button.focus(); + }); + await page.keyboard.press("Escape"); + await page.waitForFunction(() => document.querySelector("[data-task-inspector-top-layer]")?.open === false); + assert.equal(await drawer.isVisible(), true, "Escape closes a topmost dialog before the Task inspector"); + await page.locator("[data-task-inspector-top-layer]").evaluate(element => element.remove()); + } + + const proof = drawer.locator(".personal-task-completion-proof"); + await proof.locator(":scope > summary").click(); + const proofField = proof.getByRole("textbox", { name: "原执行证明(可选)" }); + const draft = `draft survives resize at ${width}`; + await proofField.fill(draft); + const resizedWidth = overlayExpected ? 1512 : 1024; + await page.setViewportSize({ height: 844, width: resizedWidth }); + await assertModality(!overlayExpected); + assert.equal(await drawer.isVisible(), true, "Crossing the inspector breakpoint keeps the selected Task"); + assert.equal(new URL(page.url()).searchParams.get("todo"), "todo-progress-current"); + assert.equal(await proofField.inputValue(), draft, "Crossing the inspector breakpoint preserves the draft"); + await page.setViewportSize({ height, width }); + await assertModality(overlayExpected); + assert.equal(await proofField.inputValue(), draft, "Returning across the inspector breakpoint preserves the draft"); + await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); await page.waitForURL(value => !value.searchParams.has("todo"), { timeout: 3000 }); assert.deepEqual(scope(), initialScope, "Closing a Task preserves the selected Goal, source and view"); await page.waitForFunction(() => document.activeElement?.closest(".personal-task-card")?.textContent.includes("Current Todo")); assert.equal(await opener.evaluate(element => document.activeElement === element), true); + await page.waitForFunction(() => { + const workspaceMain = document.querySelector(".personal-workspace-main"); + return !workspaceMain?.inert + && getComputedStyle(document.documentElement).overflow !== "hidden" + && getComputedStyle(document.body).overflow !== "hidden"; + }); await page.reload({ waitUntil: "networkidle" }); await opener.waitFor(); assert.equal(await drawer.count(), 0, "Reload cannot resurrect a dismissed Task"); @@ -93,6 +199,19 @@ try { 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 === 390) { + await opener.click(); + await drawer.waitFor(); + await opener.evaluate(element => element.remove()); + await page.keyboard.press("Escape"); + 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), + true, + "A removed Task opener falls back to the Task list instead of another Task", + ); + } assert.equal(api.turnRequests.length, 0, "Reading and returning cannot launch model work"); assert.equal(api.actionApplies.length, 0, "Closing a Task cannot mutate its authority"); assert.equal(workspace.errors.length, 0, workspace.errors.join(" | ")); @@ -101,7 +220,7 @@ try { await workspace.close(); } } - console.log("task-inspector-route: passed (packaged desktop/390px, default overview and explicit view links, keyboard/reduced motion, close/reload, Back/Forward, deliberate and unavailable deep links, no model or authority writes)"); + console.log("task-inspector-route: passed (packaged 1512/1024/768/390/320px, responsive modal/focus/background/draft behavior, nested Escape, opener-loss fallback, routing, no model or authority writes)"); } finally { await browser?.close(); server?.kill("SIGTERM"); 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 0c72ad84b1..4d1334805c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -50,6 +50,7 @@ import { parseTodoResumeCondition } from "./todo-resume-condition"; import { MarkdownText } from "./markdown"; import { formatMonitorDate } from "./monitor-readback"; import { parseTodoCompletionProof } from "./todo-completion-feedback"; +import { useDrawerOverlay } from "./workspace-shell"; function subagentModelRequest(include: boolean, model: string, effort: string) { if (!include) return {}; @@ -168,6 +169,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const drawerRef = useRef(null); const returnFocusRef = useRef(null); const titleRef = useRef(null); + const taskInspectorOverlay = useDrawerOverlay(drawerRef, selection.kind === "todo"); + const taskInspectorModal = selection.kind === "todo" && taskInspectorOverlay; const selectionIdentity = selection.kind === "run" ? `run:${selection.item.runId}` : selection.kind === "proposal" ? `proposal:${selection.item.previewId}` : selection.kind === "todo" ? `todo:${selection.item.goalId}:${selection.item.todoId}` @@ -270,29 +273,57 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention return () => window.cancelAnimationFrame(frame); }, [selectionIdentity]); + useEffect(() => { + if (!taskInspectorModal || drawerRef.current?.contains(document.activeElement)) return; + titleRef.current?.focus(); + }, [taskInspectorModal]); + const closeDrawer = useCallback(() => { const returnFocus = returnFocusRef.current; onClose(); - window.requestAnimationFrame(() => returnFocus?.focus()); + window.requestAnimationFrame(() => { + if (returnFocus && returnFocus !== document.body) { + returnFocus.focus(); + if (document.activeElement === returnFocus) return; + } + const fallback = document.querySelector('[data-active-goal-view="tasks"] .personal-task-board') + ?? document.querySelector('.personal-goal-tabs button[aria-current="page"]'); + fallback?.focus(); + }); }, [onClose]); useEffect(() => { function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented) return; if (event.key === "Escape") { + if (document.querySelector("dialog[open]")) return; + const openMenu = Array.from(drawerRef.current?.querySelectorAll( + ".personal-task-management[open], .personal-compact-menu[open]", + ) ?? []).at(-1); + if (openMenu) { + event.preventDefault(); + openMenu.open = false; + openMenu.querySelector(":scope > summary")?.focus(); + return; + } event.preventDefault(); closeDrawer(); return; } - if (event.key === "Tab" && selection.kind !== "todo") { + if (event.key === "Tab" && (selection.kind !== "todo" || taskInspectorModal)) { const focusable = Array.from(drawerRef.current?.querySelectorAll(focusableSelector) ?? []) .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]; - if (event.shiftKey && document.activeElement === first) { + const activeIndex = focusable.indexOf(document.activeElement as HTMLElement); + if (activeIndex === -1) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && activeIndex === 0) { event.preventDefault(); last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { + } else if (!event.shiftKey && activeIndex === focusable.length - 1) { event.preventDefault(); first.focus(); } @@ -302,7 +333,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention return () => { document.removeEventListener("keydown", onKeyDown); }; - }, [closeDrawer, selection.kind]); + }, [closeDrawer, selection.kind, taskInspectorModal]); const operationUnknown = selection.kind === "proposal" && selection.item.reviewPlan?.operationFrame?.kind === "result" @@ -610,7 +641,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention return (
item.message.role === "assistant" && item.message.pending); return ( -
+
{t("header.tasks")}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 4f7024ae7e..cfb4ca9245 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -252,7 +252,10 @@ assert.match(drawer, /event\.key === "Tab"/, "Drawer traps keyboard focus"); assert.match(shell, /event\.key !== "Tab"/, "Mobile Goal navigation traps keyboard focus"); assert.match(shell, /restoreFocusRef\.current\?\.focus\(\)/, "Mobile Goal navigation restores focus on close"); assert.match(shell, /aria-modal=\{mobileSidebarOpen \? true : undefined\}/, "Mobile Goal navigation exposes modal semantics"); -assert.match(shell, /inert=\{mobileSidebarOpen \|\| undefined\}/, "Mobile Goal navigation removes background content from keyboard navigation"); +assert.match(shell, /inert=\{mobileSidebarOpen \|\| drawerOverlay \|\| undefined\}/, "Mobile Goal navigation and the narrow Task inspector remove background content from keyboard navigation"); +assert.match(drawer, /useDrawerOverlay\(drawerRef, selection\.kind === "todo"\)/, "The Task inspector derives modality from the rendered overlay"); +assert.match(drawer, /aria-modal=\{selection\.kind === "todo" \? taskInspectorModal \|\| undefined : true\}/, "Only the narrow Task inspector becomes modal"); +assert.match(drawer, /document\.querySelector\("dialog\[open\]"\)/, "Escape leaves an overlaid dialog in control before the Task inspector"); assert.doesNotMatch(timeline, /
/, "The full timeline is not a live region"); assert.match(timeline, /className="personal-live-region"/, "Timeline has a dedicated live region"); assert.match(drawer, /t\("drawer\.correctionTextarea", \{ agent:[\s\S]*goal:[\s\S]*run:/, "Correction label includes its scoped context"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx index 0cf175de64..b0554d4553 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-shell.tsx @@ -1,8 +1,33 @@ -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { useWorkspaceI18n } from "./i18n"; import type { WorkspaceTheme } from "./workspace-theme"; +export function useDrawerOverlay(ref: RefObject, enabled: boolean) { + const [overlay, setOverlay] = useState(false); + useLayoutEffect(() => { + if (!enabled) { + setOverlay(false); + return; + } + const element = ref.current?.closest("[data-context-drawer]") ?? ref.current; + if (!element) return; + const update = () => { + const next = getComputedStyle(element).position === "fixed"; + setOverlay(current => current === next ? current : next); + }; + const observer = new ResizeObserver(update); + observer.observe(element); + window.addEventListener("resize", update); + update(); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + }; + }, [enabled, ref]); + return enabled && overlay; +} + export function WorkspaceShell({ overlay, drawer, @@ -29,9 +54,25 @@ export function WorkspaceShell({ theme?: WorkspaceTheme; }) { const { t } = useWorkspaceI18n(); + const drawerRef = useRef(null); + const drawerOverlay = useDrawerOverlay(drawerRef, drawerOpen && drawerMode.startsWith("inspector")); const sidebarRef = useRef(null); const restoreFocusRef = useRef(null); + useEffect(() => { + if (!drawerOverlay) return; + const root = document.documentElement; + const body = document.body; + const previousRootOverflow = root.style.overflow; + const previousBodyOverflow = body.style.overflow; + root.style.overflow = "hidden"; + body.style.overflow = "hidden"; + return () => { + root.style.overflow = previousRootOverflow; + body.style.overflow = previousBodyOverflow; + }; + }, [drawerOverlay]); + useEffect(() => { if (!mobileSidebarOpen) return; restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; @@ -68,9 +109,11 @@ export function WorkspaceShell({ {mobileSidebarOpen ?
-
{notice}{main}
- {drawerOpen ? : null} +
{notice}{main}
+ {drawerOpen ? : null}
); } From 2f5352e87e1fb6baeb7a8dab206b5e5184c2ef19 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 27 Oct 2026 00:49:10 +0800 Subject: [PATCH 2/2] fix(ui): preserve task inspector escape ownership Signed-off-by: song --- .github/workflows/python-tests.yml | 1 + .../smoke/task-inspector-route-smoke.mjs | 30 +++++++++++++++++++ .../personal-workspace/context-drawer.tsx | 2 +- .../personal-workspace-contract.test.mjs | 2 +- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 600fa15c9b..38bea36287 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -191,6 +191,7 @@ jobs: npm run smoke:chat-turn-acceptance-retry npm run smoke:chat-upgrade npm run smoke:delegation-search + npm run smoke:task-inspector-route - name: Qualify packaged team evidence and feedback recovery if: matrix.shard == 2 working-directory: apps/presentation/dashboard diff --git a/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs b/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs index afb6692dce..49a8be8cdd 100644 --- a/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs +++ b/apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs @@ -102,6 +102,14 @@ try { ); } } + if (width === 1024) { + await drawer.locator(".personal-inspector-size").click(); + await assertModality(true); + assert.equal(await page.locator("[data-context-drawer]").getAttribute("data-drawer-mode"), "inspector-full"); + await drawer.locator(".personal-inspector-size").click(); + await assertModality(true); + assert.equal(await page.locator("[data-context-drawer]").getAttribute("data-drawer-mode"), "inspector"); + } await page.screenshot({ path: resolve(outputDir, `task-inspector-modal-${width}.png`), animations: "disabled" }); const management = drawer.locator(".personal-task-management"); @@ -199,6 +207,28 @@ try { 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) { + await opener.click(); + await drawer.waitFor(); + await page.evaluate(() => { + const dialog = document.createElement("dialog"); + dialog.dataset.taskInspectorModelessDialog = "true"; + const button = document.createElement("button"); + button.textContent = "Modeless dialog"; + dialog.append(button); + document.body.append(dialog); + dialog.show(); + button.focus(); + }); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + assert.equal( + await page.locator("[data-task-inspector-modeless-dialog]").evaluate(element => element.open), + true, + "A modeless dialog does not consume Escape from the Task inspector", + ); + await page.locator("[data-task-inspector-modeless-dialog]").evaluate(element => element.remove()); + } if (width === 390) { await opener.click(); await drawer.waitFor(); 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 4d1334805c..1d0a0b4fad 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -296,7 +296,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention function onKeyDown(event: KeyboardEvent) { if (event.defaultPrevented) return; if (event.key === "Escape") { - if (document.querySelector("dialog[open]")) return; + if (document.querySelector("dialog[open]:modal")) return; const openMenu = Array.from(drawerRef.current?.querySelectorAll( ".personal-task-management[open], .personal-compact-menu[open]", ) ?? []).at(-1); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index cfb4ca9245..465add2bd6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -255,7 +255,7 @@ assert.match(shell, /aria-modal=\{mobileSidebarOpen \? true : undefined\}/, "Mob assert.match(shell, /inert=\{mobileSidebarOpen \|\| drawerOverlay \|\| undefined\}/, "Mobile Goal navigation and the narrow Task inspector remove background content from keyboard navigation"); assert.match(drawer, /useDrawerOverlay\(drawerRef, selection\.kind === "todo"\)/, "The Task inspector derives modality from the rendered overlay"); assert.match(drawer, /aria-modal=\{selection\.kind === "todo" \? taskInspectorModal \|\| undefined : true\}/, "Only the narrow Task inspector becomes modal"); -assert.match(drawer, /document\.querySelector\("dialog\[open\]"\)/, "Escape leaves an overlaid dialog in control before the Task inspector"); +assert.match(drawer, /document\.querySelector\("dialog\[open\]:modal"\)/, "Escape leaves a modal dialog in control before the Task inspector"); assert.doesNotMatch(timeline, /
/, "The full timeline is not a live region"); assert.match(timeline, /className="personal-live-region"/, "Timeline has a dedicated live region"); assert.match(drawer, /t\("drawer\.correctionTextarea", \{ agent:[\s\S]*goal:[\s\S]*run:/, "Correction label includes its scoped context");