mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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,
|
||||
|
||||
187
ui/src/ui/components/resizable-divider.test.ts
Normal file
187
ui/src/ui/components/resizable-divider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user