From efb1a7cb02a313b93473bbed8c67f49bd6501958 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:07:16 -0500 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + scripts/check-deadcode-unused-files.mjs | 24 ++- scripts/deadcode-unused-files.allowlist.mjs | 5 + .../check-deadcode-unused-files.test.ts | 19 ++ .../ui/components/resizable-divider.test.ts | 187 ++++++++++++++++++ ui/src/ui/components/resizable-divider.ts | 133 +++++++++++-- ui/src/ui/views/chat.ts | 2 + 7 files changed, 346 insertions(+), 25 deletions(-) create mode 100644 ui/src/ui/components/resizable-divider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3315959676..08c49bcb501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/check-deadcode-unused-files.mjs b/scripts/check-deadcode-unused-files.mjs index 184c4ea691c..29901a6fca2 100644 --- a/scripts/check-deadcode-unused-files.mjs +++ b/scripts/check-deadcode-unused-files.mjs @@ -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 && diff --git a/scripts/deadcode-unused-files.allowlist.mjs b/scripts/deadcode-unused-files.allowlist.mjs index 1d4baec7e1b..e403f1cc339 100644 --- a/scripts/deadcode-unused-files.allowlist.mjs +++ b/scripts/deadcode-unused-files.allowlist.mjs @@ -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"]; diff --git a/test/scripts/check-deadcode-unused-files.test.ts b/test/scripts/check-deadcode-unused-files.test.ts index 67adf292ee9..f169ef88ffa 100644 --- a/test/scripts/check-deadcode-unused-files.test.ts +++ b/test/scripts/check-deadcode-unused-files.test.ts @@ -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, diff --git a/ui/src/ui/components/resizable-divider.test.ts b/ui/src/ui/components/resizable-divider.test.ts new file mode 100644 index 00000000000..6de8f7ddc23 --- /dev/null +++ b/ui/src/ui/components/resizable-divider.test.ts @@ -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((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +async function renderDivider() { + render( + html` +
+ +
+ `, + container, + ); + + const root = container.querySelector("#split-root"); + const divider = container.querySelector("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).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); + }); +}); diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/ui/components/resizable-divider.ts index 1e85c4b3f40..502e33b655e 100644 --- a/ui/src/ui/components/resizable-divider.ts +++ b/ui/src/ui/components/resizable-divider.ts @@ -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")) { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index eecc94fc67f..d2084571c37 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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` props.onSplitRatioChange?.(e.detail.splitRatio)} >