Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 152 additions & 3 deletions apps/presentation/dashboard/smoke/task-inspector-route-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@ 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();
const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`;
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" });
Expand All @@ -39,23 +46,130 @@ 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();
await page.keyboard.press("Enter");
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`,
);
}
}
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");
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");
Expand Down Expand Up @@ -93,6 +207,41 @@ 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();
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(" | "));
Expand All @@ -101,7 +250,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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
Expand Down Expand Up @@ -168,6 +169,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
const drawerRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const titleRef = useRef<HTMLHeadingElement>(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}`
Expand Down Expand Up @@ -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<HTMLElement>('[data-active-goal-view="tasks"] .personal-task-board')
?? document.querySelector<HTMLElement>('.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]:modal")) return;
const openMenu = Array.from(drawerRef.current?.querySelectorAll<HTMLDetailsElement>(
".personal-task-management[open], .personal-compact-menu[open]",
) ?? []).at(-1);
if (openMenu) {
event.preventDefault();
openMenu.open = false;
openMenu.querySelector<HTMLElement>(":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<HTMLElement>(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();
}
Expand All @@ -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"
Expand Down Expand Up @@ -610,7 +641,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
return (
<div
aria-labelledby="personal-drawer-title"
aria-modal={selection.kind === "todo" ? undefined : "true"}
aria-modal={selection.kind === "todo" ? taskInspectorModal || undefined : true}
className="personal-context-drawer"
data-context-kind={selection.kind}
ref={drawerRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export function GoalTasksView({
&& conversation.slice(latestUserIndex + 1).some((item) => item.message.role === "assistant" && item.message.pending);

return (
<section aria-label={t("header.tasks")} className={`personal-task-board${listView ? " is-list-view" : ""}`}>
<section aria-label={t("header.tasks")} className={`personal-task-board${listView ? " is-list-view" : ""}`} tabIndex={-1}>
<header className="personal-task-view-toolbar">
<div><strong>{t("header.tasks")}</strong></div>
<div className="personal-task-view-switch" role="group" aria-label={t("tasks.viewLabel")}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\]:modal"\)/, "Escape leaves a modal dialog in control before the Task inspector");
assert.doesNotMatch(timeline, /<div aria-live="polite" className="personal-channel-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");
Expand Down
Loading
Loading