diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index d6a9b85ca..6792ee147 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -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 diff --git a/canvas/src/components/tabs/__tests__/ChatTab.talkToUserBanner.test.tsx b/canvas/src/components/tabs/__tests__/ChatTab.talkToUserBanner.test.tsx new file mode 100644 index 000000000..fceea0389 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ChatTab.talkToUserBanner.test.tsx @@ -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, + 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[0]["data"]; + +describe("ChatTab — talk_to_user disabled banner", () => { + it("is hidden when talkToUserEnabled is true", () => { + render(); + expect(screen.queryByText(/not enabled to chat/i)).toBeNull(); + }); + + it("renders the banner when talkToUserEnabled is false", () => { + render(); + expect(screen.getByText(/not enabled to chat/i)).not.toBeNull(); + }); + + it("renders the Enable button", () => { + render(); + 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(); + 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(); + 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); + }); +});