fix(ui): name split-pane focus targets for assistive technology (#149989)

This commit is contained in:
Peter Steinberger 2026-09-16 09:09:10 -07:00 • committed by GitHub
parent c2da5d3636
commit 71a614a099
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 87 additions and 19 deletions

View file

@ -209,7 +209,7 @@ Chat error banners, including cloud runner failures, show short messages in full
- **Split view:** open it from the chat title bar (beside the thread diff, background tasks, and thread files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own thread, transcript, composer, and tool stream.
- Agents with the `screen` tool can request the same pane, sidebar, terminal, browser, focus, and navigation changes while a capable Control UI is connected. Protocol v1 applies the command to every connected capable Control UI; see [Screen](/tools/screen).
- Drag a session from the sidebar into chat to open it in a pane. An animated drop preview glides between zones and labels the outcome — "Split" over the exact half a new pane will occupy, "Open here" over a whole pane — and drops also work from single-pane mode.
- The active split pane drives the sidebar selection and URL. Closing a pane that holds keyboard focus returns focus to the surviving pane's header. Its title bar adds split and close controls; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
- The active split pane drives the sidebar selection and URL. Closing a pane that holds keyboard focus returns focus to the surviving pane's header, which is labeled with the session title for assistive technology. Its title bar adds split and close controls; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
- On narrow screens, split view keeps the layout but renders only the active pane at the full available width and height, including its header with the close control. Widening the window restores the saved column and row proportions without losing drafts.
- If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model.
- On the New Session page, press **Cmd+Enter** on macOS or **Ctrl+Enter** elsewhere to create and start the draft in a background session without leaving the page. The selected local, cloud-profile, or paired-device placement is preserved. With the **Modifier+Enter** send preference, use **Cmd/Ctrl+Shift+Enter** for background start; Cmd/Ctrl+Enter remains ordinary submit. Explicit Draft visibility keeps its create-only behavior. A completion notice offers to open the new session.

View file

@ -12,12 +12,16 @@ import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"
const suite = createControlUiE2eSuite({ name: "Split pane close focus" });
suite.define(() => {
it.each([
{ count: 2, closed: 1, surviving: 0 },
{ count: 2, closed: 0, surviving: 1 },
{ count: 3, closed: 2, surviving: 1 },
{ count: 2, closed: 1, surviving: 0, presentation: "chat" },
{ count: 2, closed: 0, surviving: 1, presentation: "chat" },
{ count: 3, closed: 2, surviving: 1, presentation: "chat" },
{ count: 2, closed: 1, surviving: 0, presentation: "read-only" },
{ count: 2, closed: 1, surviving: 0, presentation: "dashboard" },
])(
"returns focus to the surviving pane after closing pane $closed of $count",
async ({ count, closed, surviving }) => {
"returns named focus to the surviving $presentation pane after closing pane $closed of $count",
async ({ count, closed, surviving, presentation }) => {
const editable = presentation !== "read-only";
const composerVisible = presentation !== "dashboard";
await suite.withPage({ viewport: { width: 1920, height: 1000 } }, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
@ -25,6 +29,9 @@ suite.define(() => {
key: `agent:main:close-${index}`,
kind: "direct",
label: `Research ${index}`,
...(!editable
? { visibility: "read-only" as const, sharingRole: "viewer" as const }
: {}),
updatedAt: 100 - index,
}));
const home = {
@ -34,10 +41,31 @@ suite.define(() => {
updatedAt: 80,
};
await page.addInitScript(
({ storageKey, sessions, closedIndex }) => {
({ storageKey, sessions, closedIndex, survivingIndex, dashboard }) => {
localStorage.setItem(
storageKey,
JSON.stringify({
...(dashboard
? {
sidebarSessionLayouts: {
[sessions[survivingIndex]!.key]: {
columns: [
{
id: "dashboard-column",
side: "right",
panels: [{ id: "dashboard", slot: "dashboard" }],
activePanelId: "dashboard",
width: 480,
height: 360,
},
],
mainPanelId: "dashboard",
open: true,
expanded: true,
},
},
}
: {}),
chatSplitLayout: {
activePaneId: `p${closedIndex + 1}`,
columnWeights: sessions.map(() => 1 / sessions.length),
@ -54,12 +82,28 @@ suite.define(() => {
storageKey: controlUiBundledSettingsStorageKey(suite.server.baseUrl),
sessions: rows,
closedIndex: closed,
survivingIndex: surviving,
dashboard: !composerVisible,
},
);
const gateway = await installMockGateway(page, {
sessionKey: rows[closed]!.key,
operatorScopes: editable ? undefined : ["operator.read"],
methodResponses: {
"board.get": {
sessionKey: rows[surviving]!.key,
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
widgets: [],
},
},
sessions: [...rows, home],
featureMethods: [...defaultControlUiFeatureMethods, "chat.history", "chat.send"],
featureMethods: [
...defaultControlUiFeatureMethods,
"chat.history",
"chat.send",
"board.get",
],
sessionTranscripts: Object.fromEntries(
[...rows, home].map((row) => [
row.key,
@ -80,14 +124,28 @@ suite.define(() => {
.filter({ hasText: `Conversation Research ${closed}` });
const composerA = paneA.locator(".agent-chat__composer-combobox textarea");
const composerB = paneB.locator(".agent-chat__composer-combobox textarea");
await composerA.fill("Draft A");
await composerB.fill("Draft B");
await page.locator(".sidebar-footer-bar__home").click();
if (editable && composerVisible) {
await composerA.fill("Draft A");
}
if (editable) {
await composerB.fill("Draft B");
}
if (!editable) {
await expect.poll(() => composerA.isDisabled()).toBe(true);
}
if (!composerVisible) {
await paneA.locator('[data-panel-slot="dashboard"][data-region="main"]').waitFor();
await expect.poll(() => composerA.isVisible()).toBe(false);
}
if (editable) {
await page.locator(".sidebar-footer-bar__home").click();
}
const homeComposer = page.locator(
"openclaw-assistant-panel .agent-chat__composer-combobox textarea",
);
await homeComposer.fill("Home draft");
await composerB.focus();
if (editable) {
await homeComposer.fill("Home draft");
}
const close = paneB.locator(".chat-pane__close-pane");
await close.focus();
expect(await close.evaluate((element) => element === document.activeElement)).toBe(true);
@ -125,16 +183,20 @@ suite.define(() => {
inHome: Boolean(document.activeElement?.closest("openclaw-assistant-panel")),
}));
const afterClose = await focus();
const accessibilityAfterClose = await page.locator(":focus").ariaSnapshot();
const draftAfterClose = await composerA.inputValue();
await page.screenshot({ path: path.join(suite.artifactDir, "after-close.png") });
await page.keyboard.type(" continuation");
if (editable && composerVisible) {
await page.keyboard.type(" continuation");
}
const afterTyping = await focus();
const observations = {
afterClose,
accessibilityAfterClose,
afterTyping,
draftAfterClose,
finalDraftA: await composerA.inputValue(),
finalHomeDraft: await homeComposer.inputValue(),
finalHomeDraft: editable ? await homeComposer.inputValue() : null,
url: page.url(),
errors,
methods: (await gateway.getRequests()).map(({ method }) => method),
@ -146,13 +208,17 @@ suite.define(() => {
await page.screenshot({
path: path.join(suite.artifactDir, "after-keyboard-continuation.png"),
});
expect.soft(draftAfterClose).toBe("Draft A");
expect.soft(observations.finalDraftA).toBe("Draft A continuation");
expect.soft(observations.finalHomeDraft).toBe("Home draft");
expect.soft(draftAfterClose).toBe(editable && composerVisible ? "Draft A" : "");
expect
.soft(observations.finalDraftA)
.toBe(editable && composerVisible ? "Draft A continuation" : "");
expect.soft(observations.finalHomeDraft).toBe(editable ? "Home draft" : null);
expect.soft(observations.methods.filter((method) => method === "chat.send")).toEqual([]);
expect.soft(errors).toEqual([]);
expect.soft(afterClose.inChat).toBe(true);
expect.soft(afterClose.className).toContain("chat-pane__header");
expect
.soft(accessibilityAfterClose.split("\n")[0])
.toBe(`- group "Research ${surviving}":`);
});
},
);

View file

@ -303,6 +303,8 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
return html`
<div
class="chat-pane__header ${hasFaceControl ? "chat-pane__header--centered" : ""}"
role="group"
aria-label=${props.title}
tabindex="-1"
@mousedown=${beginNativeWindowDrag}
>