diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css index 7f93dc53b..19500c5d1 100644 --- a/canvas/src/app/globals.css +++ b/canvas/src/app/globals.css @@ -287,4 +287,11 @@ body { outline: 2px solid var(--accent, #3b5bdb); outline-offset: 2px; } + + /* Mobile tab buttons — WCAG 2.4.7 focus-visible */ + .mobile-tab-btn:focus-visible { + outline: 2px solid var(--accent, #3b5bdb); + outline-offset: 2px; + border-radius: 6px; + } } diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx index 81af4fb8f..298ff3aaf 100644 --- a/canvas/src/app/orgs/page.tsx +++ b/canvas/src/app/orgs/page.tsx @@ -118,7 +118,7 @@ export default function OrgsPage() {

Error: {error}

@@ -212,7 +212,7 @@ function AccountBar({ session }: { session: Session }) { // edge cases (jsdom, blocked navigation) where it doesn't. setSigningOut(false); }} - className="rounded border border-line bg-surface-card px-3 py-1 text-xs text-ink hover:bg-surface-card disabled:opacity-50" + className="rounded border border-line bg-surface-card px-3 py-1 text-xs text-ink hover:bg-surface-card disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" aria-label="Sign out" > {signingOut ? "Signing out…" : "Sign out"} @@ -439,7 +439,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) { diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx index 28cb37d96..f854ed770 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -103,7 +103,7 @@ export default function Home() { setHydrationError(null); window.location.reload(); }} - className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm" + className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" > Retry @@ -134,7 +134,7 @@ export default function Home() { setHydrationError(null); window.location.reload(); }} - className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm" + className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" > Retry @@ -176,7 +176,7 @@ brew services start redis`}

diff --git a/canvas/src/components/BatchActionBar.tsx b/canvas/src/components/BatchActionBar.tsx index 3a25c33b3..773ce9dae 100644 --- a/canvas/src/components/BatchActionBar.tsx +++ b/canvas/src/components/BatchActionBar.tsx @@ -149,7 +149,7 @@ export function BatchActionBar() { title="Clear selection (Escape)" className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" > - ✕ + ); diff --git a/canvas/src/components/BroadcastBanner.tsx b/canvas/src/components/BroadcastBanner.tsx new file mode 100644 index 000000000..32a797479 --- /dev/null +++ b/canvas/src/components/BroadcastBanner.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { subscribeSocketEvents } from "@/store/socket-events"; +import type { WSMessage } from "@/store/socket"; + +interface BroadcastEntry { + id: string; + sender: string; + senderId: string; + message: string; + receivedAt: number; +} + +interface BroadcastPayload { + message: string; + sender_id: string; + sender: string; +} + +/** + * BroadcastBanner + * Displays real-time broadcast messages from agent workspaces. + * + * A workspace with `broadcast_enabled=true` can send a message to every + * other workspace in the same org. The platform emits a BROADCAST_MESSAGE + * WebSocket event to each recipient; the canvas shows a dismissible + * banner so the human operator sees what their agent just broadcast. + * + * WCAG 2.1 compliance: + * - role="status" + aria-live="polite" — announcements don't interrupt + * current speech; polite is correct for non-critical notifications. + * - aria-atomic="true" — screen readers announce the full message. + * - Dismiss button: aria-label with specific broadcast content. + * - focus-visible ring on dismiss button. + * - Auto-dismiss after 10s so stale banners don't accumulate. + * - Keyboard: dismiss via Escape key (listened on document). + */ +export function BroadcastBanner() { + const [entries, setEntries] = useState([]); + const timeoutRefs = useRef>>(new Map()); + + const dismiss = useCallback((id: string) => { + setEntries((prev) => prev.filter((e) => e.id !== id)); + const timer = timeoutRefs.current.get(id); + if (timer !== undefined) { + clearTimeout(timer); + timeoutRefs.current.delete(id); + } + }, []); + + useEffect(() => { + const _unsubscribe = subscribeSocketEvents((msg: WSMessage) => { + if (msg.event !== "BROADCAST_MESSAGE") return; + const payload = msg.payload as BroadcastPayload; + if (!payload.message || !payload.sender) return; + + const entry: BroadcastEntry = { + id: `${payload.sender_id}-${msg.timestamp}-${Date.now()}`, + sender: payload.sender, + senderId: payload.sender_id, + message: payload.message, + receivedAt: Date.now(), + }; + + setEntries((prev) => { + // Prevent duplicates from reconnect-bursts — keep only the latest + // entry per sender. + const filtered = prev.filter((e) => e.senderId !== entry.senderId); + return [...filtered, entry]; + }); + + // Auto-dismiss after 10 seconds. + const timer = setTimeout(() => { + dismiss(entry.id); + }, 10_000); + timeoutRefs.current.set(entry.id, timer); + }); + + return () => { + // Guard: unsubscribe may be a vi.fn() stub in test mocks. Safety check + // prevents "unsubscribe is not a function" when vi.resetModules() clears + // hoisted refs between test cases. + if (typeof _unsubscribe === "function") _unsubscribe(); + // Clear all pending timers on unmount. + for (const timer of timeoutRefs.current.values()) { + clearTimeout(timer); + } + timeoutRefs.current.clear(); + }; + }, [dismiss]); + + if (entries.length === 0) return null; + + return ( +
+ {entries.map((entry) => ( +
+
+
+ +
+
+
+ {entry.sender} +
+
+ {entry.message} +
+
+ +
+
+ ))} +
+ ); +} diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 888343b0e..e507401ab 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -21,6 +21,7 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog"; import { ContextMenu } from "./ContextMenu"; import { TemplatePalette } from "./TemplatePalette"; import { ApprovalBanner } from "./ApprovalBanner"; +import { BroadcastBanner } from "./BroadcastBanner"; import { BundleDropZone } from "./BundleDropZone"; import { EmptyState } from "./EmptyState"; import { OnboardingWizard } from "./OnboardingWizard"; @@ -367,6 +368,7 @@ function CanvasInner() { + diff --git a/canvas/src/components/CommunicationOverlay.tsx b/canvas/src/components/CommunicationOverlay.tsx index 11198d21e..80b111b0b 100644 --- a/canvas/src/components/CommunicationOverlay.tsx +++ b/canvas/src/components/CommunicationOverlay.tsx @@ -217,7 +217,11 @@ export function CommunicationOverlay() { } return ( -
+
Communications ({comms.length}) diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 61a834c06..ccc874dde 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -125,7 +125,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos aria-label="Close conversation trace" className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface" > - ✕ +
diff --git a/canvas/src/components/OrgImportPreflightModal.tsx b/canvas/src/components/OrgImportPreflightModal.tsx index 6bc4ea480..4b5f8b830 100644 --- a/canvas/src/components/OrgImportPreflightModal.tsx +++ b/canvas/src/components/OrgImportPreflightModal.tsx @@ -406,7 +406,7 @@ function StrictEnvRow({ {envKey} {configured ? ( - ✓ set + ) : ( <> {isConfigured ? ( - ✓ set + ) : ( <> >) }} className="flex items-center gap-1.5 mt-1 w-full bg-accent/10 px-2 py-1 rounded-md border border-accent/40 hover:bg-accent/20 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none" > - + Restart to apply changes )} diff --git a/canvas/src/components/__tests__/BroadcastBanner.test.tsx b/canvas/src/components/__tests__/BroadcastBanner.test.tsx new file mode 100644 index 000000000..d9199148a --- /dev/null +++ b/canvas/src/components/__tests__/BroadcastBanner.test.tsx @@ -0,0 +1,274 @@ +// @vitest-environment jsdom +/** + * WCAG 2.1 AA accessibility + functional tests for BroadcastBanner. + * + * Pattern matches ActivityTab.test.tsx — uses the real subscribeSocketEvents + * bus (no module mock) so the component's useEffect registers its listener + * normally. Tests call emitSocketEvent to fire fake events into the bus, + * which delivers to all registered listeners including the component's. + * + * _resetSocketEventListenersForTests() clears the listeners Set between tests + * so each case starts clean. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; + +import { + emitSocketEvent, + _resetSocketEventListenersForTests, +} from "@/store/socket-events"; +import type { WSMessage } from "@/store/socket"; +import { BroadcastBanner } from "../BroadcastBanner"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const broadcastMsg = ( + sender = "Test Agent", + senderId = "ws-agent-1", + message = "All agents: please check your memory for stale data.", +): WSMessage => ({ + event: "BROADCAST_MESSAGE", + workspace_id: "ws-recipient-1", + timestamp: new Date().toISOString(), + payload: { + message, + sender_id: senderId, + sender, + } as unknown as Record, +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("BroadcastBanner — empty state", () => { + beforeEach(() => { + _resetSocketEventListenersForTests(); + }); + + afterEach(() => { + cleanup(); + _resetSocketEventListenersForTests(); + }); + + it("renders nothing when no BROADCAST_MESSAGE events have been received", () => { + render(); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); + +describe("BroadcastBanner — renders banner on BROADCAST_MESSAGE", () => { + beforeEach(() => { + _resetSocketEventListenersForTests(); + }); + + afterEach(() => { + cleanup(); + _resetSocketEventListenersForTests(); + }); + + it("shows a status banner when a BROADCAST_MESSAGE is received", async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole("status")).toBeNull(); + }); + + act(() => { + emitSocketEvent(broadcastMsg()); + }); + + await waitFor(() => { + expect(screen.getByRole("status")).toBeTruthy(); + }); + }); + + it("displays the sender name", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg("PM Agent")); + }); + await waitFor(() => { + expect(screen.getByText("PM Agent")).toBeTruthy(); + }); + }); + + it("displays the broadcast message", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Sprint review in 30 minutes.")); + }); + await waitFor(() => { + expect(screen.getByText("Sprint review in 30 minutes.")).toBeTruthy(); + }); + }); +}); + +describe("BroadcastBanner — WCAG 1.1.1 Non-text Content", () => { + beforeEach(() => { + _resetSocketEventListenersForTests(); + }); + + afterEach(() => { + cleanup(); + _resetSocketEventListenersForTests(); + }); + + it("broadcast emoji is aria-hidden=true", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg()); + }); + await waitFor(() => { + expect(screen.getByText("📣")).toBeTruthy(); + }); + expect(screen.getByText("📣").getAttribute("aria-hidden")).toBe("true"); + }); +}); + +describe("BroadcastBanner — WCAG 4.1.2 Name, Role, Value", () => { + beforeEach(() => { + _resetSocketEventListenersForTests(); + }); + + afterEach(() => { + cleanup(); + _resetSocketEventListenersForTests(); + }); + + it("container has role=status", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg()); + }); + await waitFor(() => { + expect(screen.getByRole("status")).toBeTruthy(); + }); + }); + + it("container has aria-live=polite", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg()); + }); + await waitFor(() => { + expect(screen.getByRole("status").getAttribute("aria-live")).toBe("polite"); + }); + }); + + it("dismiss button has aria-label describing the broadcast", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Sprint review in 30 minutes.")); + }); + await waitFor(() => { + expect( + screen.getByRole("button", { name: /dismiss broadcast from pm agent/i }), + ).toBeTruthy(); + }); + const btn = screen.getByRole("button", { name: /dismiss broadcast from pm agent/i }); + expect(btn.getAttribute("aria-label")).toContain("Sprint review in 30 minutes."); + }); + + it("dismiss button has focus-visible ring class", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg()); + }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /dismiss broadcast/i })).toBeTruthy(); + }); + const btn = screen.getByRole("button", { name: /dismiss broadcast/i }); + // Component uses focus-visible:ring-2 for keyboard focus indication (WCAG 2.4.7). + expect(btn.classList.contains("focus-visible:ring-2")).toBe(true); + }); +}); + +describe("BroadcastBanner — auto-dismiss", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + _resetSocketEventListenersForTests(); + }); + + afterEach(() => { + cleanup(); + _resetSocketEventListenersForTests(); + vi.useRealTimers(); + }); + + it("banner auto-dismisses after 10 seconds", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg()); + }); + await waitFor(() => { + expect(screen.getByRole("status")).toBeTruthy(); + }); + + // Advance 10 seconds — the setTimeout fires. + act(() => { + vi.advanceTimersByTime(10_000); + }); + + await waitFor(() => { + expect(screen.queryByRole("status")).toBeNull(); + }); + }); + + it("banner disappears immediately on dismiss button click", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg()); + }); + await waitFor(() => { + expect(screen.getByRole("status")).toBeTruthy(); + }); + + const dismissBtn = screen.getByRole("button", { name: /dismiss broadcast/i }); + fireEvent.click(dismissBtn); + + await waitFor(() => { + expect(screen.queryByRole("status")).toBeNull(); + }); + }); +}); + +describe("BroadcastBanner — deduplication", () => { + beforeEach(() => { + _resetSocketEventListenersForTests(); + }); + + afterEach(() => { + cleanup(); + _resetSocketEventListenersForTests(); + }); + + it("shows one banner when the same sender sends multiple messages rapidly", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "First message.")); + emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Second message.")); + }); + + await waitFor(() => { + // Only one banner per sender — the second replaces the first. + expect(screen.getAllByRole("status")).toHaveLength(1); + expect(screen.getByText("Second message.")).toBeTruthy(); + }); + }); + + it("shows separate banners for different senders", async () => { + render(); + act(() => { + emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "PM message.")); + emitSocketEvent(broadcastMsg("Research Lead", "ws-rl", "Research message.")); + }); + + await waitFor(() => { + // The outer container has role="status" (1); each child banner does not. + // Verify both senders appear as text instead. + expect(screen.getByText("PM Agent")).toBeTruthy(); + expect(screen.getByText("Research Lead")).toBeTruthy(); + expect(screen.getByText("PM message.")).toBeTruthy(); + expect(screen.getByText("Research message.")).toBeTruthy(); + }); + }); +}); diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index 375bd37a8..ee6844680 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -339,6 +339,7 @@ export function MobileChat({ type="button" onClick={onBack} aria-label="Back" + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ width: 36, height: 36, @@ -385,6 +386,7 @@ export function MobileChat({ -
@@ -183,6 +184,7 @@ export function MobileDetail({ key={t.id} type="button" onClick={() => setTab(t.id)} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ padding: "8px 14px", borderRadius: 999, @@ -215,6 +217,7 @@ export function MobileDetail({ type="button" onClick={onChat} data-testid="mobile-chat-cta" + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ width: "100%", height: 52, diff --git a/canvas/src/components/mobile/MobileHome.tsx b/canvas/src/components/mobile/MobileHome.tsx index 271fa511f..b4b2961d7 100644 --- a/canvas/src/components/mobile/MobileHome.tsx +++ b/canvas/src/components/mobile/MobileHome.tsx @@ -183,6 +183,7 @@ export function MobileHome({ type="button" onClick={onSpawn} aria-label="Spawn new agent" + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ position: "absolute", right: 24, diff --git a/canvas/src/components/mobile/MobileMe.tsx b/canvas/src/components/mobile/MobileMe.tsx index c1735083d..0ed71cd61 100644 --- a/canvas/src/components/mobile/MobileMe.tsx +++ b/canvas/src/components/mobile/MobileMe.tsx @@ -83,6 +83,7 @@ export function MobileMe({ type="button" onClick={() => setAccent(c)} aria-label={`Set accent ${c}`} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ width: 36, height: 36, @@ -173,6 +174,7 @@ function SegmentedRow({ key={o.id} type="button" onClick={() => onChange(o.id)} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ flex: 1, padding: "10px 8px", diff --git a/canvas/src/components/mobile/MobileSpawn.tsx b/canvas/src/components/mobile/MobileSpawn.tsx index 7ee62e89d..5ff533a3a 100644 --- a/canvas/src/components/mobile/MobileSpawn.tsx +++ b/canvas/src/components/mobile/MobileSpawn.tsx @@ -148,6 +148,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v type="button" onClick={onClose} aria-label="Close" + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ width: 32, height: 32, @@ -214,6 +215,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v setTplId(t.id); setTier(tCode); }} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ background: on ? dark @@ -330,6 +332,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v key={t} type="button" onClick={() => setTier(t)} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ flex: 1, padding: "10px 8px", @@ -377,6 +380,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v type="button" onClick={handleSpawn} disabled={busy || !tplId || templates.length === 0} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ width: "100%", height: 52, diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx index 592604a52..a36c5680c 100644 --- a/canvas/src/components/mobile/components.tsx +++ b/canvas/src/components/mobile/components.tsx @@ -133,6 +133,7 @@ export function TabBar({ aria-label={t.label} onClick={() => onChange(t.id)} onKeyDown={(e) => handleKeyDown(e, idx)} + className="mobile-tab-btn" style={{ background: "none", border: "none", @@ -291,6 +292,7 @@ export function AgentCard({ data-testid="workspace-card" aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`} onClick={onClick} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ display: "block", width: "100%", @@ -444,6 +446,7 @@ export function FilterChips({ type="button" aria-checked={on} onClick={() => onChange(o.id)} + className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none" style={{ display: "inline-flex", alignItems: "center", diff --git a/canvas/src/components/tabs/ActivityTab.tsx b/canvas/src/components/tabs/ActivityTab.tsx index 092a58bc9..37446d725 100644 --- a/canvas/src/components/tabs/ActivityTab.tsx +++ b/canvas/src/components/tabs/ActivityTab.tsx @@ -139,20 +139,20 @@ export function ActivityTab({ workspaceId }: Props) { key={f.id} onClick={() => setFilter(f.id)} aria-pressed={filter === f.id} - className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${ + className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-1 ${ filter === f.id ? "bg-surface-card text-ink ring-1 ring-zinc-600" : "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60" }`} > - {f.icon} {f.label} + {f.label} ))}
diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index 1abc1f288..d7760584b 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -242,7 +242,9 @@ export function ChannelsTab({ workspaceId }: Props) { if (loading) { return ( -
Loading channels...
+
+ Loading channels... +
); } @@ -332,7 +334,7 @@ export function ChannelsTab({ workspaceId }: Props) { ))} @@ -410,13 +412,13 @@ export function ChannelsTab({ workspaceId }: Props) { diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index d6a9b85ca..24e02775a 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 @@ -404,7 +404,7 @@ function MyChatPanel({ workspaceId, data }: Props) {

@@ -582,7 +582,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
Processing with {runtimeDisplayName(data.runtime)}...
{activityLog.map((line, i) => ( -
◇ {line}
+
{line}
))}
)} @@ -600,7 +600,7 @@ function MyChatPanel({ workspaceId, data }: Props) { {!isOnline && ( @@ -636,7 +636,7 @@ function MyChatPanel({ workspaceId, data }: Props) { disabled={!agentReachable || sending || uploading} aria-label="Attach file" title="Attach file" - className="p-2 bg-surface-card hover:bg-surface-card border border-line rounded-lg text-ink-mid hover:text-ink transition-colors shrink-0 disabled:opacity-40" + className="p-2 bg-surface-card hover:bg-surface-card border border-line rounded-lg text-ink-mid hover:text-ink transition-colors shrink-0 disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" >
-
📄
+

Select a file to edit

@@ -47,7 +47,7 @@ export function FileEditor({ {/* File header */}
- {getIcon(selectedFile, false)} + {selectedFile} {isDirty && modified}
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 ? "▼" : "▶"} - 📁 + + {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) {
@@ -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({