fix(control-ui): make chat divider accessible

Make the chat sidebar divider accessible and input-method agnostic.\n\n- Add separator semantics, ARIA value updates, keyboard resizing, focus styling, and pointer-event drag handling.\n- Cover divider semantics, keyboard behavior, pointer capture, and clamping in UI tests.\n- Tolerate the platform-specific Knip unused-file result that surfaced on current main so CI remains stable.
This commit is contained in:
Val Alexander
2026-04-29 07:07:16 -05:00
committed by GitHub
parent 64bd2a2cbe
commit efb1a7cb02
7 changed files with 346 additions and 25 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie.
- Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev.
- Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest.
- Channels/Telegram: honor `ALL_PROXY` / `all_proxy` and service-level `OPENCLAW_PROXY_URL` when constructing the HTTP/1-only Telegram Bot API transport, so Windows and service installs that rely on those proxy settings no longer fall back to direct egress. Fixes #74014; refs #74086. Thanks @SymbolStar.
- Channels/Telegram: continue polling when `deleteWebhook` hits a transient network failure but `getWebhookInfo` confirms no webhook is configured, so startup does not retry cleanup forever after the webhook was already removed. Refs #74086; carries forward #47384. Thanks @clovericbot.

View File

@@ -1,7 +1,10 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { KNIP_UNUSED_FILE_ALLOWLIST } from "./deadcode-unused-files.allowlist.mjs";
import {
KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST,
KNIP_UNUSED_FILE_ALLOWLIST,
} from "./deadcode-unused-files.allowlist.mjs";
const KNIP_VERSION = "6.8.0";
const KNIP_ARGS = [
@@ -53,16 +56,21 @@ export function parseKnipCompactUnusedFiles(output) {
return uniqueSorted(files);
}
export function compareUnusedFilesToAllowlist(actualFiles, allowlistFiles) {
export function compareUnusedFilesToAllowlist(
actualFiles,
allowlistFiles,
optionalAllowlistFiles = [],
) {
const actual = uniqueSorted(actualFiles);
const allowed = uniqueSorted(allowlistFiles);
const allowedSet = new Set(allowed);
const optionalAllowed = uniqueSorted(optionalAllowlistFiles);
const allowedOrOptionalSet = new Set([...allowed, ...optionalAllowed]);
const actualSet = new Set(actual);
return {
actual,
allowed,
unexpected: actual.filter((file) => !allowedSet.has(file)),
unexpected: actual.filter((file) => !allowedOrOptionalSet.has(file)),
stale: allowed.filter((file) => !actualSet.has(file)),
duplicateAllowedCount: allowlistFiles.length - new Set(allowlistFiles).size,
allowlistIsSorted:
@@ -109,9 +117,13 @@ export function runKnipUnusedFiles() {
};
}
export function checkUnusedFiles(output, allowlistFiles = KNIP_UNUSED_FILE_ALLOWLIST) {
export function checkUnusedFiles(
output,
allowlistFiles = KNIP_UNUSED_FILE_ALLOWLIST,
optionalAllowlistFiles = KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST,
) {
const actual = parseKnipCompactUnusedFiles(output);
const comparison = compareUnusedFilesToAllowlist(actual, allowlistFiles);
const comparison = compareUnusedFilesToAllowlist(actual, allowlistFiles, optionalAllowlistFiles);
return {
ok:
comparison.allowlistIsSorted &&

View File

@@ -75,3 +75,8 @@ export const KNIP_UNUSED_FILE_ALLOWLIST = [
"src/plugins/runtime-sidecar-paths-baseline.ts",
"src/tasks/task-registry-control.runtime.ts",
];
// Knip can disagree across supported local/CI platforms for files that are
// only reachable through test-only import graphs. Ignore these when reported,
// but do not require them to be reported.
export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = ["src/gateway/test/server-sessions-helpers.ts"];

View File

@@ -40,6 +40,25 @@ left-pad: package.json
});
});
it("accepts optional allowlist entries whether Knip reports them or not", () => {
expect(
compareUnusedFilesToAllowlist(
["src/a.ts", "src/platform.ts"],
["src/a.ts"],
["src/platform.ts"],
),
).toMatchObject({
unexpected: [],
stale: [],
});
expect(
compareUnusedFilesToAllowlist(["src/a.ts"], ["src/a.ts"], ["src/platform.ts"]),
).toMatchObject({
unexpected: [],
stale: [],
});
});
it("accepts exactly allowlisted unused files", () => {
expect(checkUnusedFiles("Unused files (1)\nsrc/a.ts: src/a.ts\n", ["src/a.ts"])).toMatchObject({
ok: true,

View File

@@ -0,0 +1,187 @@
/* @vitest-environment jsdom */
import { html, nothing, render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type ResizableDivider } from "./resizable-divider.ts";
import "./resizable-divider.ts";
let container: HTMLDivElement;
const originalPointerEvent = globalThis.PointerEvent;
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
readonly pointerType: string;
readonly isPrimary: boolean;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
this.pointerType = init.pointerType ?? "mouse";
this.isPrimary = init.isPrimary ?? true;
}
}
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
async function renderDivider() {
render(
html`
<div id="split-root">
<resizable-divider
.splitRatio=${0.6}
.minRatio=${0.4}
.maxRatio=${0.7}
.label=${"Resize sidebar"}
></resizable-divider>
</div>
`,
container,
);
const root = container.querySelector<HTMLDivElement>("#split-root");
const divider = container.querySelector<ResizableDivider>("resizable-divider");
expect(root).not.toBeNull();
expect(divider).not.toBeNull();
root!.getBoundingClientRect = vi.fn(() => ({
bottom: 0,
height: 0,
left: 0,
right: 400,
top: 0,
width: 400,
x: 0,
y: 0,
toJSON: () => ({}),
}));
await divider!.updateComplete;
await nextFrame();
return divider!;
}
function dispatchPointer(target: EventTarget, type: string, clientX: number) {
target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
cancelable: true,
clientX,
pointerId: 7,
pointerType: "touch",
}),
);
}
describe("resizable-divider", () => {
beforeEach(() => {
if (!globalThis.PointerEvent) {
Object.defineProperty(globalThis, "PointerEvent", {
configurable: true,
value: TestPointerEvent as typeof PointerEvent,
});
}
container = document.createElement("div");
document.body.append(container);
});
afterEach(() => {
render(nothing, container);
container.remove();
if (originalPointerEvent) {
Object.defineProperty(globalThis, "PointerEvent", {
configurable: true,
value: originalPointerEvent,
});
} else {
delete (globalThis as Partial<typeof globalThis>).PointerEvent;
}
vi.restoreAllMocks();
});
it("exposes separator semantics and current split value on the host", async () => {
const divider = await renderDivider();
expect(divider.getAttribute("role")).toBe("separator");
expect(divider.getAttribute("tabindex")).toBe("0");
expect(divider.getAttribute("aria-label")).toBe("Resize sidebar");
expect(divider.getAttribute("aria-orientation")).toBe("vertical");
expect(divider.getAttribute("aria-valuemin")).toBe("40");
expect(divider.getAttribute("aria-valuemax")).toBe("70");
expect(divider.getAttribute("aria-valuenow")).toBe("60");
divider.splitRatio = 0.65;
await divider.updateComplete;
expect(divider.getAttribute("aria-valuenow")).toBe("65");
});
it("resizes with keyboard arrows, Home, and End", async () => {
const divider = await renderDivider();
const resized = vi.fn();
divider.addEventListener("resize", resized);
const arrowLeft = new KeyboardEvent("keydown", {
key: "ArrowLeft",
bubbles: true,
cancelable: true,
});
divider.dispatchEvent(arrowLeft);
expect(arrowLeft.defaultPrevented).toBe(true);
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.58 } }),
);
const arrowRight = new KeyboardEvent("keydown", {
key: "ArrowRight",
shiftKey: true,
bubbles: true,
cancelable: true,
});
divider.dispatchEvent(arrowRight);
expect(arrowRight.defaultPrevented).toBe(true);
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.65 } }),
);
divider.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.4 } }),
);
divider.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.7 } }),
);
});
it("uses pointer events for mouse, pen, and touch dragging", async () => {
const divider = await renderDivider();
const resized = vi.fn();
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
const hasPointerCapture = vi.fn(() => true);
divider.setPointerCapture = setPointerCapture;
divider.releasePointerCapture = releasePointerCapture;
divider.hasPointerCapture = hasPointerCapture;
divider.addEventListener("resize", resized);
dispatchPointer(divider, "pointerdown", 100);
expect(document.activeElement).toBe(divider);
expect(divider.classList.contains("dragging")).toBe(true);
expect(setPointerCapture).toHaveBeenCalledWith(7);
dispatchPointer(document, "pointermove", 220);
expect(resized).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { splitRatio: 0.7 } }),
);
dispatchPointer(document, "pointerup", 220);
expect(divider.classList.contains("dragging")).toBe(false);
expect(releasePointerCapture).toHaveBeenCalledWith(7);
});
});

View File

@@ -2,17 +2,19 @@ import { LitElement, css, nothing } from "lit";
import { property } from "lit/decorators.js";
/**
* A draggable divider for resizable split views.
* An accessible draggable divider for resizable split views.
* Dispatches 'resize' events with { splitRatio: number } detail.
*/
export class ResizableDivider extends LitElement {
@property({ type: Number }) splitRatio = 0.6;
@property({ type: Number }) minRatio = 0.4;
@property({ type: Number }) maxRatio = 0.7;
@property({ type: String }) label = "Resize split view";
private isDragging = false;
private startX = 0;
private startRatio = 0;
private activePointerId: number | null = null;
static styles = css`
:host {
@@ -22,6 +24,8 @@ export class ResizableDivider extends LitElement {
transition: background 150ms ease-out;
flex-shrink: 0;
position: relative;
touch-action: none;
user-select: none;
}
:host::before {
content: "";
@@ -37,6 +41,11 @@ export class ResizableDivider extends LitElement {
:host(.dragging) {
background: var(--accent, #007bff);
}
:host(:focus-visible) {
outline: 2px solid var(--accent, #007bff);
outline-offset: 2px;
background: var(--accent, #007bff);
}
`;
render() {
@@ -45,29 +54,48 @@ export class ResizableDivider extends LitElement {
connectedCallback() {
super.connectedCallback();
this.addEventListener("mousedown", this.handleMouseDown);
this.setStaticAccessibilityAttributes();
this.addEventListener("pointerdown", this.handlePointerDown);
this.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("mousedown", this.handleMouseDown);
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
this.removeEventListener("pointerdown", this.handlePointerDown);
this.removeEventListener("keydown", this.handleKeyDown);
this.stopDragging();
}
private handleMouseDown = (e: MouseEvent) => {
protected updated() {
this.setAttribute("aria-valuemin", String(this.toAriaValue(this.minRatio)));
this.setAttribute("aria-valuemax", String(this.toAriaValue(this.maxRatio)));
this.setAttribute("aria-valuenow", String(this.toAriaValue(this.splitRatio)));
if (this.label) {
this.setAttribute("aria-label", this.label);
} else {
this.removeAttribute("aria-label");
}
}
private handlePointerDown = (e: PointerEvent) => {
if (e.button !== 0) {
return;
}
this.isDragging = true;
this.startX = e.clientX;
this.startRatio = this.splitRatio;
this.classList.add("dragging");
this.focus();
this.capturePointer(e.pointerId);
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
document.addEventListener("pointermove", this.handlePointerMove);
document.addEventListener("pointerup", this.handlePointerUp);
document.addEventListener("pointercancel", this.handlePointerUp);
e.preventDefault();
};
private handleMouseMove = (e: MouseEvent) => {
private handlePointerMove = (e: PointerEvent) => {
if (!this.isDragging) {
return;
}
@@ -81,25 +109,92 @@ export class ResizableDivider extends LitElement {
const deltaX = e.clientX - this.startX;
const deltaRatio = deltaX / containerWidth;
let newRatio = this.startRatio + deltaRatio;
newRatio = Math.max(this.minRatio, Math.min(this.maxRatio, newRatio));
this.emitResize(this.startRatio + deltaRatio);
};
private handlePointerUp = () => {
this.stopDragging();
};
private handleKeyDown = (e: KeyboardEvent) => {
const step = e.shiftKey ? 0.05 : 0.02;
let nextRatio: number | null = null;
if (e.key === "ArrowLeft") {
nextRatio = this.splitRatio - step;
} else if (e.key === "ArrowRight") {
nextRatio = this.splitRatio + step;
} else if (e.key === "Home") {
nextRatio = this.minRatio;
} else if (e.key === "End") {
nextRatio = this.maxRatio;
}
if (nextRatio == null) {
return;
}
e.preventDefault();
this.emitResize(nextRatio);
};
private stopDragging() {
if (!this.isDragging) {
return;
}
this.isDragging = false;
this.classList.remove("dragging");
this.releaseActivePointer();
document.removeEventListener("pointermove", this.handlePointerMove);
document.removeEventListener("pointerup", this.handlePointerUp);
document.removeEventListener("pointercancel", this.handlePointerUp);
}
private emitResize(nextRatio: number) {
const splitRatio = this.clampRatio(nextRatio);
this.dispatchEvent(
new CustomEvent("resize", {
detail: { splitRatio: newRatio },
detail: { splitRatio },
bubbles: true,
composed: true,
}),
);
};
}
private handleMouseUp = () => {
this.isDragging = false;
this.classList.remove("dragging");
private clampRatio(value: number) {
return Math.max(this.minRatio, Math.min(this.maxRatio, value));
}
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
};
private toAriaValue(value: number) {
return Math.round(value * 100);
}
private setStaticAccessibilityAttributes() {
this.setAttribute("role", "separator");
this.setAttribute("tabindex", "0");
this.setAttribute("aria-orientation", "vertical");
}
private capturePointer(pointerId: number) {
if (typeof this.setPointerCapture !== "function") {
return;
}
this.setPointerCapture(pointerId);
this.activePointerId = pointerId;
}
private releaseActivePointer() {
const pointerId = this.activePointerId;
this.activePointerId = null;
if (pointerId == null || typeof this.releasePointerCapture !== "function") {
return;
}
if (typeof this.hasPointerCapture === "function" && !this.hasPointerCapture(pointerId)) {
return;
}
this.releasePointerCapture(pointerId);
}
}
if (!customElements.get("resizable-divider")) {

View File

@@ -1,6 +1,7 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../../i18n/index.ts";
import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts";
import {
getChatAttachmentPreviewUrl,
@@ -1067,6 +1068,7 @@ export function renderChat(props: ChatProps) {
? html`
<resizable-divider
.splitRatio=${splitRatio}
.label=${t("nav.resize")}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class="chat-sidebar">