diff --git a/canvas/src/components/tabs/FilesTab/FileTree.tsx b/canvas/src/components/tabs/FilesTab/FileTree.tsx
index 0e32bc455..2c5800878 100644
--- a/canvas/src/components/tabs/FilesTab/FileTree.tsx
+++ b/canvas/src/components/tabs/FilesTab/FileTree.tsx
@@ -199,6 +199,9 @@ function TreeItem({
return (
onToggleDir(node.path)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onToggleDir(node.path);
+ }
+ }}
onContextMenu={(e) => openContextMenu(e, node)}
{...dragProps}
>
- {isLoading ? "…" : expanded ? "▼" : "▶"}
- 📁
+ {isLoading ? "…" : expanded ? "▼" : "▶"}
+ 📁
{node.name}
diff --git a/canvas/src/components/tabs/ScheduleTab.tsx b/canvas/src/components/tabs/ScheduleTab.tsx
index b25fbf1d6..f0af58709 100644
--- a/canvas/src/components/tabs/ScheduleTab.tsx
+++ b/canvas/src/components/tabs/ScheduleTab.tsx
@@ -313,7 +313,7 @@ export function ScheduleTab({ workspaceId }: Props) {
{schedules.length === 0 && !showForm ? (
-
⏲
+
⏲
No schedules yet
Add a schedule to run tasks automatically — daily scans, periodic reports, standup reminders.
diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx
index 74278a232..6fdfe8ad4 100644
--- a/canvas/src/components/tabs/SkillsTab.tsx
+++ b/canvas/src/components/tabs/SkillsTab.tsx
@@ -325,7 +325,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
setShowRegistry(true)}
- className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
+ className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-1"
aria-expanded="false"
aria-controls="plugins-section"
>
@@ -349,7 +349,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
setShowRegistry(!showRegistry)}
- className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
+ className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-1"
aria-expanded={showRegistry}
aria-controls="plugins-registry"
>
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);
+ });
+});
diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx
index b44ae1c0a..d4718a90f 100644
--- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx
+++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx
@@ -405,7 +405,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
Retry
@@ -610,7 +610,7 @@ function PeerTabButton({
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={onClick}
- className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
+ className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
active
? "border-b-2 border-cyan-500 text-cyan-200"
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid"
diff --git a/canvas/src/components/tabs/chat/AttachmentViews.tsx b/canvas/src/components/tabs/chat/AttachmentViews.tsx
index 0d01a425d..7a2a47ea2 100644
--- a/canvas/src/components/tabs/chat/AttachmentViews.tsx
+++ b/canvas/src/components/tabs/chat/AttachmentViews.tsx
@@ -33,7 +33,7 @@ export function PendingAttachmentPill({