fix(canvas/ChatTab): add WCAG 2.4.7 focus-visible ring to the talk_to_user Enable button

PR #1256 has an outstanding WCAG blocker: the "Enable" button that
re-enables agent-to-user messaging lacks a focus-visible ring, making
keyboard navigation invisible for sighted keyboard users.

Adds focus-visible:ring-2 (with matching accent colour and zinc-900 offset)
to the Enable button className, satisfying WCAG 2.4.7 (Focus Visible).

Also adds ChatTab.talkToUserBanner.test.tsx with 5 test cases:
  - Banner hidden when talkToUserEnabled=true
  - Banner shown when talkToUserEnabled=false
  - Enable button renders
  - Enable button calls PATCH /workspaces/:id/abilities with correct payload
  - Enable button has focus-visible:ring-2 class (WCAG 2.4.7)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · core-uiux 2026-05-16 10:25:29 +00:00
parent 6cfe76b6dd
commit 6383276e51
2 changed files with 133 additions and 1 deletions

View File

@ -383,7 +383,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
// ignore — user will see no change and can retry
}
}}
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
>
Enable
</button>

View File

@ -0,0 +1,132 @@
// @vitest-environment jsdom
//
// Tests for the talk_to_user disabled banner in ChatTab.
//
// When a workspace has talk_to_user_enabled=false, the agent cannot send
// canvas messages to the user. A banner appears with an "Enable" button that
// calls PATCH /workspaces/:id/abilities with { talk_to_user_enabled: true }.
//
// Covers:
// - Banner hidden when talkToUserEnabled=true
// - Banner shown when talkToUserEnabled=false
// - "Enable" button calls PATCH /workspaces/:id/abilities with correct payload
// - "Enable" button has focus-visible:ring class (WCAG 2.4.7)
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import React from "react";
afterEach(cleanup);
// Track patch calls for assertions so tests can inspect them.
const patchCalls: { path: string; body: unknown }[] = [];
// var: declaration hoisted to top of file (before vi.mock calls run), and
// initializer runs eagerly at parse time — available to hoisted factory bodies.
var mockUpdateNodeData = vi.fn();
vi.mock("@/lib/api", () => {
const apiGet = vi.fn(() => Promise.resolve([]));
const apiPost = vi.fn(() => Promise.resolve({}));
const apiPatch = vi.fn(() => Promise.resolve({}));
return {
api: {
get: (path: string) => apiGet(path),
post: (path: string, body: unknown) => {
patchCalls.push({ path, body });
return apiPost(path, body);
},
del: vi.fn(),
patch: (path: string, body: unknown) => {
patchCalls.push({ path, body });
return apiPatch(path, body);
},
put: vi.fn(),
},
};
});
vi.mock("@/store/canvas", () => {
const state = {
agentMessages: {} as Record<string, unknown[]>,
consumeAgentMessages: () => [] as unknown[],
updateNodeData: mockUpdateNodeData,
};
return {
useCanvasStore: Object.assign(
vi.fn((selector?: (s: typeof state) => unknown) =>
selector ? selector(state) : state,
),
{ getState: () => state },
),
};
});
beforeEach(() => {
mockUpdateNodeData.mockReset();
patchCalls.length = 0;
// jsdom doesn't implement scrollIntoView; ChatTab calls it after render.
Element.prototype.scrollIntoView = vi.fn();
// Stub IntersectionObserver — lazy-history sentinel uses it.
class FakeIO {
observe() {}
unobserve() {}
disconnect() {}
}
(window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
});
import { ChatTab } from "../ChatTab";
const minimalData = {
status: "online" as const,
runtime: "claude-code",
currentTask: null,
} as unknown as Parameters<typeof ChatTab>[0]["data"];
describe("ChatTab — talk_to_user disabled banner", () => {
it("is hidden when talkToUserEnabled is true", () => {
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: true }} />);
expect(screen.queryByText(/not enabled to chat/i)).toBeNull();
});
it("renders the banner when talkToUserEnabled is false", () => {
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: false }} />);
expect(screen.getByText(/not enabled to chat/i)).not.toBeNull();
});
it("renders the Enable button", () => {
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: false }} />);
const btns = screen.getAllByRole("button");
const enableBtn = btns.find((b) => b.textContent?.trim() === "Enable");
expect(enableBtn).not.toBeUndefined();
});
it("Enable button calls PATCH /workspaces/:id/abilities with talk_to_user_enabled: true", async () => {
render(<ChatTab workspaceId="ws-test-456" data={{ ...minimalData, talkToUserEnabled: false }} />);
const btns = screen.getAllByRole("button");
const enableBtn = btns.find((b) => b.textContent?.trim() === "Enable")!;
fireEvent.click(enableBtn);
await waitFor(() => {
expect(patchCalls).toContainEqual({ path: "/workspaces/ws-test-456/abilities", body: { talk_to_user_enabled: true } });
});
});
// Note: we cannot test the "banner disappears after store update" DOM
// outcome here because MyChatPanel reads data.talkToUserEnabled from its
// props (passed from ChatTab), not from the store. The store update is
// a side-effect that updates the canvas nodes array; it does not flow
// back into the ChatTab prop chain. The PATCH call (verified above) is
// the primary integration point — the store update is an implementation
// detail that callers verify via the canvas-level integration test suite.
it("Enable button has focus-visible:ring-2 class (WCAG 2.4.7)", () => {
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: false }} />);
const btns = screen.getAllByRole("button");
const enableBtn = btns.find((b) => b.textContent?.trim() === "Enable")!;
// The fix adds focus-visible:ring-2 (not the shorthand focus-visible:ring).
// Both satisfy WCAG 2.4.7 by making keyboard focus clearly visible.
expect(enableBtn.classList.contains("focus-visible:ring-2")).toBe(true);
});
});