diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx
index 81af4fb8..94778891 100644
--- a/canvas/src/app/orgs/page.tsx
+++ b/canvas/src/app/orgs/page.tsx
@@ -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:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1"
aria-label="Sign out"
>
{signingOut ? "Signing out…" : "Sign out"}
diff --git a/canvas/src/components/BroadcastBanner.tsx b/canvas/src/components/BroadcastBanner.tsx
new file mode 100644
index 00000000..6f86ccba
--- /dev/null
+++ b/canvas/src/components/BroadcastBanner.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useCallback } from "react";
+import { useCanvasStore } from "@/store/canvas";
+
+/** Org-wide broadcast banner.
+ *
+ * Rendered at the top of the canvas (below the toolbar) whenever the store
+ * holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
+ * - sender name (workspace that issued the broadcast)
+ * - the message text
+ * - a dismiss button
+ *
+ * Dismissing an entry removes it from the store via consumeBroadcastMessages.
+ * The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
+ * on page refresh since they are not persisted server-side; this is intentional
+ * (the platform's activity log already provides the audit trail).
+ */
+export function BroadcastBanner() {
+ const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
+ const dismissBroadcastMessage = useCanvasStore((s) => s.dismissBroadcastMessage);
+
+ const handleDismiss = useCallback(
+ (id: string) => {
+ dismissBroadcastMessage(id);
+ },
+ [dismissBroadcastMessage],
+ );
+
+ if (broadcastMessages.length === 0) return null;
+
+ return (
+
+ {broadcastMessages.map((msg) => (
+
+
+ {/* Megaphone icon */}
+
+
+
+
+ Broadcast from{" "}
+ {msg.sender}
+
+
+ {msg.message}
+
+
+
+ {/* Dismiss button */}
+
+
+
+ ))}
+
+ );
+}
diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx
index 888343b0..e507401a 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/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx
index 54eceff3..e081d126 100644
--- a/canvas/src/components/MissingKeysModal.tsx
+++ b/canvas/src/components/MissingKeysModal.tsx
@@ -471,7 +471,7 @@ function ProviderPickerModal({
{onOpenSettings && (
@@ -480,7 +480,7 @@ function ProviderPickerModal({
diff --git a/canvas/src/components/__tests__/BroadcastBanner.test.tsx b/canvas/src/components/__tests__/BroadcastBanner.test.tsx
new file mode 100644
index 00000000..efc52678
--- /dev/null
+++ b/canvas/src/components/__tests__/BroadcastBanner.test.tsx
@@ -0,0 +1,111 @@
+// @vitest-environment jsdom
+/**
+ * Tests for BroadcastBanner component.
+ * WCAG compliance: role=alert, aria-live=polite, per-message dismiss.
+ */
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen, cleanup, fireEvent } from "@testing-library/react";
+import { BroadcastBanner } from "../BroadcastBanner";
+import { useCanvasStore } from "@/store/canvas";
+
+const mockDismiss = vi.fn();
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: vi.fn((selector: (s: ReturnType
) => unknown) => {
+ const state = {
+ broadcastMessages: [] as Array<{
+ id: string;
+ senderId: string;
+ sender: string;
+ message: string;
+ timestamp: string;
+ }>,
+ dismissBroadcastMessage: mockDismiss,
+ };
+ return selector(state);
+ }),
+}));
+
+afterEach(() => {
+ cleanup();
+ mockDismiss.mockClear();
+ vi.clearAllMocks();
+});
+
+const broadcastMessages = [
+ { id: "m1", senderId: "ws-ops", sender: "Ops Agent", message: "Deploy in 5 min", timestamp: "2026-05-16T00:00:00Z" },
+ { id: "m2", senderId: "ws-sre", sender: "SRE Team", message: "Maintenance window tonight", timestamp: "2026-05-16T00:01:00Z" },
+];
+
+function setup(messages = broadcastMessages) {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (selector: (s: { broadcastMessages: typeof broadcastMessages; dismissBroadcastMessage: typeof mockDismiss }) => unknown) => {
+ const state = {
+ broadcastMessages: messages,
+ dismissBroadcastMessage: mockDismiss,
+ };
+ return selector(state);
+ }
+ );
+ return render();
+}
+
+describe("BroadcastBanner", () => {
+ it("renders nothing when there are no messages", () => {
+ setup([]);
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("renders a role=alert banner for each broadcast message", () => {
+ setup();
+ const alerts = screen.getAllByRole("alert");
+ expect(alerts).toHaveLength(2);
+ });
+
+ it("shows sender name and message content", () => {
+ setup();
+ expect(screen.getByText("Deploy in 5 min")).toBeTruthy();
+ expect(screen.getByText("Ops Agent")).toBeTruthy();
+ expect(screen.getByText("Maintenance window tonight")).toBeTruthy();
+ expect(screen.getByText("SRE Team")).toBeTruthy();
+ });
+
+ it("each banner has a dismiss button with accessible label", () => {
+ setup();
+ const buttons = screen.getAllByRole("button", { name: /dismiss/i });
+ expect(buttons).toHaveLength(2);
+ });
+
+ it("dismissing a banner calls dismissBroadcastMessage with the correct id", () => {
+ setup();
+ const buttons = screen.getAllByRole("button", { name: /dismiss/i });
+ // Dismiss the second message (Maintenance window)
+ fireEvent.click(buttons[1]);
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
+ expect(mockDismiss).toHaveBeenCalledWith("m2");
+ });
+
+ it("dismissing one banner does not dismiss others", () => {
+ setup();
+ const buttons = screen.getAllByRole("button", { name: /dismiss/i });
+ fireEvent.click(buttons[0]);
+ expect(mockDismiss).toHaveBeenCalledWith("m1");
+ expect(mockDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it("dismiss button has focus-visible ring (WCAG 2.4.7)", () => {
+ setup();
+ const button = screen.getAllByRole("button", { name: /dismiss/i })[0];
+ expect(button.className).toContain("focus-visible:ring");
+ });
+
+ it("sender and message text use adequate contrast color classes", () => {
+ setup();
+ // text-blue-300 (#93C5FD) on blue-950/80 ≈ 5.9:1 contrast — WCAG AA ✓
+ const senderLabel = screen.getByText("Ops Agent").closest("div");
+ expect(senderLabel?.className).toContain("text-blue-300");
+ // text-blue-50 (#EFF6FF) on blue-950/80 ≈ 11.7:1 — WCAG AAA ✓
+ const messageEl = screen.getByText("Deploy in 5 min");
+ expect(messageEl.className).toContain("text-blue-50");
+ });
+});
diff --git a/canvas/src/components/__tests__/Canvas.a11y.test.tsx b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
index 341a2c7a..02d0dd71 100644
--- a/canvas/src/components/__tests__/Canvas.a11y.test.tsx
+++ b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
@@ -73,6 +73,8 @@ const mockStoreState = {
clearSelection: vi.fn(),
toggleNodeSelection: vi.fn(),
deletingIds: new Set(),
+ broadcastMessages: [],
+ consumeBroadcastMessages: vi.fn(() => []),
};
vi.mock("@/store/canvas", () => ({
@@ -100,6 +102,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
+vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
vi.mock("../settings", () => ({
diff --git a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
index 76d9be78..8ce8d01a 100644
--- a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
+++ b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
@@ -91,6 +91,8 @@ const mockStoreState = {
// an empty Set mirrors the idle canvas and doesn't interact with
// any pan/fit behaviour under test here.
deletingIds: new Set(),
+ broadcastMessages: [],
+ consumeBroadcastMessages: vi.fn(() => []),
};
vi.mock("@/store/canvas", () => ({
@@ -117,6 +119,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
+vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
vi.mock("../settings", () => ({
diff --git a/canvas/src/components/__tests__/ThemeToggle.test.tsx b/canvas/src/components/__tests__/ThemeToggle.test.tsx
index 08b875a4..4128d3d7 100644
--- a/canvas/src/components/__tests__/ThemeToggle.test.tsx
+++ b/canvas/src/components/__tests__/ThemeToggle.test.tsx
@@ -24,12 +24,8 @@ vi.mock("@/lib/theme-provider", () => ({
})),
}));
-// Wrap cleanup in act() so any pending React state updates (e.g. from
-// keyDown handlers that call setTheme) flush before DOM unmount. Without
-// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
-// when the handleKeyDown callback tries to query the DOM mid-teardown.
afterEach(() => {
- act(() => { cleanup(); });
+ cleanup();
vi.clearAllMocks();
});
@@ -150,7 +146,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
act(() => { radios[2].focus(); });
- act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
+ fireEvent.keyDown(radios[2], { key: "ArrowRight" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -164,7 +160,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowLeft should go to dark (index 2)
act(() => { radios[0].focus(); });
- act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
+ fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
@@ -178,7 +174,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowDown should go to system (index 1)
act(() => { radios[0].focus(); });
- act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
+ fireEvent.keyDown(radios[0], { key: "ArrowDown" });
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
@@ -191,7 +187,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render();
const radios = screen.getAllByRole("radio");
act(() => { radios[2].focus(); });
- act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
+ fireEvent.keyDown(radios[2], { key: "Home" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -204,14 +200,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render();
const radios = screen.getAllByRole("radio");
act(() => { radios[0].focus(); });
- act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
+ fireEvent.keyDown(radios[0], { key: "End" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("does nothing on unrelated keys", () => {
render();
const radios = screen.getAllByRole("radio");
- act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
+ fireEvent.keyDown(radios[0], { key: "Enter" });
expect(mockSetTheme).not.toHaveBeenCalled();
});
});
diff --git a/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx b/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx
index da2a13b6..aec9512c 100644
--- a/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx
+++ b/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx
@@ -195,6 +195,47 @@ describe("DropTargetBadge — renders ghost slot + badge for valid drag target",
expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
});
+ it("ghost has aria-hidden=true (decorative visual affordance)", () => {
+ _getInternalNode.mockReturnValue({
+ internals: { positionAbsolute: { x: 100, y: 200 } },
+ measured: { width: 220, height: 500 },
+ });
+ setFlowMock(({ x, y }: { x: number; y: number }) => {
+ if (x === 210 && y === 200) return { x: 420, y: 400 };
+ if (x === 116 && y === 330) return { x: 232, y: 660 };
+ if (x === 356 && y === 460) return { x: 712, y: 920 };
+ if (x === 100 && y === 200) return { x: 200, y: 400 };
+ if (x === 320 && y === 700) return { x: 640, y: 1400 };
+ return { x: x * 2, y: y * 2 };
+ });
+
+ setStore({
+ dragOverNodeId: "ws-target",
+ nodes: [
+ { id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } },
+ ],
+ });
+ render();
+ const ghost = screen.getByTestId("ghost-slot");
+ expect(ghost.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("drop badge has role=status and aria-label including target name", () => {
+ _getInternalNode.mockReturnValue({
+ internals: { positionAbsolute: { x: 100, y: 200 } },
+ measured: { width: 220, height: 120 },
+ });
+ setFlowMock(({ x, y }: { x: number; y: number }) => ({ x: x * 2, y: y * 2 }));
+ setStore({
+ dragOverNodeId: "ws-target",
+ nodes: [{ id: "ws-target", data: { name: "Ops Workspace" }, parentId: null }],
+ });
+ render();
+ const badge = screen.getByTestId("drop-badge");
+ expect(badge.getAttribute("role")).toBe("status");
+ expect(badge.getAttribute("aria-label")).toBe("Drop target: Ops Workspace");
+ });
+
it("ghost is hidden when slot falls entirely outside parent bounds", () => {
_getInternalNode.mockReturnValue({
internals: { positionAbsolute: { x: 100, y: 200 } },
diff --git a/canvas/src/components/mobile/MobileCanvas.tsx b/canvas/src/components/mobile/MobileCanvas.tsx
index acdaa168..1037918b 100644
--- a/canvas/src/components/mobile/MobileCanvas.tsx
+++ b/canvas/src/components/mobile/MobileCanvas.tsx
@@ -205,6 +205,7 @@ export function MobileCanvas({
type="button"
onClick={resetView}
aria-label="Reset zoom"
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
position: "absolute",
right: 14,
@@ -272,6 +273,7 @@ export function MobileCanvas({
key={l.agent.id}
type="button"
onClick={() => onOpen(l.agent.id)}
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
position: "absolute",
left: `${l.x}%`,
@@ -376,6 +378,7 @@ export function MobileCanvas({
type="button"
onClick={onSpawn}
aria-label="Spawn new agent"
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
position: "absolute",
right: 24,
diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx
index 375bd37a..73863402 100644
--- a/canvas/src/components/mobile/MobileChat.tsx
+++ b/canvas/src/components/mobile/MobileChat.tsx
@@ -6,21 +6,21 @@
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useMemo, useRef, useState } from "react";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
+import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
-import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
-import {
- useChatHistory,
- useChatSend,
- useChatSocket,
-} from "@/components/tabs/chat/hooks";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
+interface ChatMessage {
+ id: string;
+ role: "user" | "agent" | "system";
+ text: string;
+ ts: string;
+}
+
const formatStoredTimestamp = (iso: string): string => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
@@ -29,171 +29,29 @@ const formatStoredTimestamp = (iso: string): string => {
type SubTab = "my" | "a2a";
-function MarkdownBubble({
- children,
- dark,
- accent,
-}: {
- children: string;
- dark: boolean;
- accent: string;
-}) {
- const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
- const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
- const linkColor = accent;
- const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
-
- return (
- (
- {children}
- ),
- a: ({ href, children }) => (
-
- {children}
-
- ),
- pre: ({ children }) => (
-
- {children}
-
- ),
- code: ({ children, className }) => {
- const isBlock = className != null && String(className).length > 0;
- if (isBlock) {
- return (
-
- {children}
-
- );
- }
- return (
-
- {children}
-
- );
- },
- ul: ({ children }) => (
-
- ),
- ol: ({ children }) => (
-
- {children}
-
- ),
- li: ({ children }) => {children},
- strong: ({ children }) => (
- {children}
- ),
- em: ({ children }) => {children},
- h1: ({ children }) => (
- {children}
- ),
- h2: ({ children }) => (
- {children}
- ),
- h3: ({ children }) => (
- {children}
- ),
- h4: ({ children }) => (
- {children}
- ),
- h5: ({ children }) => (
- {children}
- ),
- h6: ({ children }) => (
- {children}
- ),
- blockquote: ({ children }) => (
-
- {children}
-
- ),
- hr: () => (
-
- ),
- table: ({ children }) => (
-
- ),
- thead: ({ children }) => {children},
- th: ({ children }) => (
-
- {children}
- |
- ),
- td: ({ children }) => (
-
- {children}
- |
- ),
- }}
- >
- {children}
-
- );
+interface A2AResponseShape {
+ result?: {
+ parts?: Array<{ kind?: string; text?: string }>;
+ };
+ error?: { message?: string };
}
+// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
+interface ApiChatMessage {
+ id: string;
+ role: string; // "user" | "agent" | "system"
+ content: string;
+ timestamp: string;
+}
+
+interface ChatHistoryResponse {
+ messages: ApiChatMessage[];
+ reached_end: boolean;
+}
+
+const formatTime = (date: Date) =>
+ date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
+
export function MobileChat({
agentId,
dark,
@@ -204,40 +62,31 @@ export function MobileChat({
onBack: () => void;
}) {
const p = usePalette(dark);
+ // Selecting `nodes` stably avoids the `.find()` anti-pattern that
+ // creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
+ // Bootstrap from the canvas store's per-workspace message buffer so the
+ // user sees their prior thread on entry. The store is updated by the
+ // socket → ChatTab flows the desktop runs; on mobile we read from the
+ // same buffer to keep state coherent across viewports.
+ // NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
+ // that creates a new [] reference on every store update when the key is
+ // absent, causing infinite re-render (React error #185).
+ const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
+ // Start empty — history is loaded via useEffect below.
+ const [messages, setMessages] = useState([]);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState("my");
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true); // history is loading on mount
+ const [historyError, setHistoryError] = useState(null);
const scrollRef = useRef(null);
+ // Guard: don't treat the initial store population as a live push.
+ // Set to false after the first render completes.
+ const initDoneRef = useRef(false);
const composerRef = useRef(null);
- const fileInputRef = useRef(null);
- const [pendingFiles, setPendingFiles] = useState([]);
-
- const {
- messages,
- loading: historyLoading,
- loadError: historyError,
- loadInitial,
- appendMessageDeduped,
- } = useChatHistory(agentId);
-
- const {
- sending,
- uploading,
- sendMessage,
- error: sendError,
- clearError,
- releaseSendGuards,
- } = useChatSend(agentId, {
- getHistoryMessages: () => messages,
- onUserMessage: appendMessageDeduped,
- onAgentMessage: appendMessageDeduped,
- });
-
- useChatSocket(agentId, {
- onAgentMessage: appendMessageDeduped,
- onSendComplete: releaseSendGuards,
- });
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -250,26 +99,81 @@ export function MobileChat({
el.style.height = `${next}px`;
}, [draft]);
+ // Fetch chat history on mount; keep merging live agentMessages while the
+ // panel is open. InitDoneRef prevents the initial store snapshot from
+ // triggering the live-merge path (the store buffer is populated by
+ // ChatTab on desktop, not on mobile — this effect loads history as the
+ // mobile-native path).
+ useEffect(() => {
+ let cancelled = false;
+
+ const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
+ id: m.id,
+ role: m.role === "user" ? "user" : "agent",
+ text: m.content,
+ ts: formatStoredTimestamp(m.timestamp),
+ });
+
+ const syncLive = () => {
+ const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
+ if (live.length > 0) {
+ setMessages((prev) => {
+ const existingIds = new Set(prev.map((m) => m.id));
+ const newOnes = live
+ .filter((m) => !existingIds.has(m.id))
+ .map((m) => ({
+ id: m.id,
+ role: "agent" as const,
+ text: m.content,
+ ts: formatStoredTimestamp(m.timestamp),
+ }));
+ return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
+ });
+ }
+ };
+
+ const bootstrap = async (): Promise<(() => void) | undefined> => {
+ setLoading(true);
+ setHistoryError(null);
+ try {
+ const res = await api.get(
+ `/workspaces/${agentId}/chat-history?limit=50`,
+ );
+ if (cancelled) return;
+ const initial = (res.messages ?? []).map(mapApiMessage);
+ setMessages(initial);
+ // Mark init done BEFORE marking loading=false so any store push
+ // that arrives in the same tick is treated as live, not init.
+ initDoneRef.current = true;
+ setLoading(false);
+ // Subscribe to live pushes after init is complete.
+ syncLive();
+ const unsubscribe = useCanvasStore.subscribe(syncLive);
+ return unsubscribe; // returned for cleanup
+ } catch (e) {
+ if (cancelled) return;
+ setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
+ setLoading(false);
+ initDoneRef.current = true;
+ return undefined;
+ }
+ };
+
+ let maybeUnsubscribe: (() => void) | undefined;
+ bootstrap().then((fn) => { maybeUnsubscribe = fn; });
+
+ return () => {
+ cancelled = true;
+ if (maybeUnsubscribe) maybeUnsubscribe();
+ };
+ }, [agentId]);
+
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
- // Consume any agent messages that arrived while history was loading.
- const initialConsumeDoneRef = useRef(false);
- useEffect(() => {
- if (historyLoading || initialConsumeDoneRef.current) return;
- initialConsumeDoneRef.current = true;
- const consume = useCanvasStore.getState().consumeAgentMessages;
- const msgs = consume(agentId);
- for (const m of msgs) {
- appendMessageDeduped(
- createMessage("agent", m.content, m.attachments),
- );
- }
- }, [historyLoading, agentId, appendMessageDeduped]);
-
if (!node) {
return (
{
- if (!fileList) return;
- const picked = Array.from(fileList);
- setPendingFiles((prev) => {
- const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
- return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
- });
- if (fileInputRef.current) fileInputRef.current.value = "";
- };
-
- const removePendingFile = (index: number) =>
- setPendingFiles((prev) => prev.filter((_, i) => i !== index));
-
const send = async () => {
const text = draft.trim();
- if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
- clearError();
+ if (!text || sending || !reachable) return;
setDraft("");
- const files = pendingFiles;
- setPendingFiles([]);
- await sendMessage(text, files);
+ setError(null);
+ setSending(true);
+ const myMsg: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "user",
+ text,
+ ts: formatTime(new Date()),
+ };
+ setMessages((m) => [...m, myMsg]);
+
+ try {
+ const res = await api.post
(`/workspaces/${agentId}/a2a`, {
+ method: "message/send",
+ params: {
+ message: {
+ role: "user",
+ messageId: crypto.randomUUID(),
+ parts: [{ kind: "text", text }],
+ },
+ },
+ });
+ const reply =
+ res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
+ if (reply) {
+ setMessages((m) => [
+ ...m,
+ {
+ id: crypto.randomUUID(),
+ role: "agent",
+ text: reply,
+ ts: formatTime(new Date()),
+ },
+ ]);
+ } else if (res.error?.message) {
+ setError(res.error.message);
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to send");
+ } finally {
+ setSending(false);
+ }
};
return (
@@ -339,6 +267,7 @@ export function MobileChat({
type="button"
onClick={onBack}
aria-label="Back"
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 36,
height: 36,
@@ -385,6 +314,7 @@ export function MobileChat({
)}
- {tab === "my" && historyLoading && (
+ {tab === "my" && loading && (
- Loading chat history…
+
⟳
+
Loading chat history…
)}
- {tab === "my" && !historyLoading && historyError && messages.length === 0 && (
+ {tab === "my" && !loading && historyError && (
Could not load chat history.
)}
- {tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
+ {tab === "my" && !loading && !historyError && messages.length === 0 && (
Send a message to start chatting.
@@ -521,9 +473,7 @@ export function MobileChat({
overflowWrap: "anywhere",
}}
>
-
- {m.content}
-
+ {m.text}
- {formatStoredTimestamp(m.timestamp)}
+ {m.ts}
);
})}
- {sendError && (
+ {error && (
- {sendError}
+ {error}
)}
@@ -581,60 +531,6 @@ export function MobileChat({
backdropFilter: "blur(14px)",
}}
>
- {pendingFiles.length > 0 && (
-
- {pendingFiles.map((f, i) => (
-
-
- {f.name}
-
-
-
- ))}
-
- )}
- onFilesPicked(e.target.files)}
- aria-hidden="true"
- />
diff --git a/canvas/src/components/mobile/MobileComms.tsx b/canvas/src/components/mobile/MobileComms.tsx
index ff3da4d4..4373222b 100644
--- a/canvas/src/components/mobile/MobileComms.tsx
+++ b/canvas/src/components/mobile/MobileComms.tsx
@@ -218,6 +218,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
key={o.id}
type="button"
onClick={() => setFilter(o.id)}
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
display: "inline-flex",
alignItems: "center",
diff --git a/canvas/src/components/mobile/MobileDetail.tsx b/canvas/src/components/mobile/MobileDetail.tsx
index 96d1bd62..f7217985 100644
--- a/canvas/src/components/mobile/MobileDetail.tsx
+++ b/canvas/src/components/mobile/MobileDetail.tsx
@@ -83,11 +83,12 @@ export function MobileDetail({
type="button"
onClick={onBack}
aria-label="Back"
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={iconButtonStyle(p, dark)}
>
{Icons.back({ size: 18 })}
-
+
{Icons.more({ size: 18 })}
@@ -183,6 +184,7 @@ export function MobileDetail({
key={t.id}
type="button"
onClick={() => setTab(t.id)}
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
padding: "8px 14px",
borderRadius: 999,
@@ -215,6 +217,7 @@ export function MobileDetail({
type="button"
onClick={onChat}
data-testid="mobile-chat-cta"
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: "100%",
height: 52,
diff --git a/canvas/src/components/mobile/MobileHome.tsx b/canvas/src/components/mobile/MobileHome.tsx
index 271fa511..4ed135fa 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:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
position: "absolute",
right: 24,
diff --git a/canvas/src/components/mobile/MobileMe.tsx b/canvas/src/components/mobile/MobileMe.tsx
index c1735083..f9d82941 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:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 36,
height: 36,
@@ -173,6 +174,7 @@ function SegmentedRow({
key={o.id}
type="button"
onClick={() => onChange(o.id)}
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
flex: 1,
padding: "10px 8px",
diff --git a/canvas/src/components/mobile/MobileSpawn.tsx b/canvas/src/components/mobile/MobileSpawn.tsx
index 7ee62e89..65ca73fd 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:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
width: 32,
height: 32,
@@ -210,10 +211,12 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
{
setTplId(t.id);
setTier(tCode);
}}
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
background: on
? dark
@@ -329,7 +332,10 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
setTier(t)}
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
flex: 1,
padding: "10px 8px",
@@ -375,8 +381,10 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
{
renderChat(mockAgentId);
});
expect(api.get).toHaveBeenCalledWith(
- expect.stringContaining(`/workspaces/${mockAgentId}/chat-history`),
+ `/workspaces/${mockAgentId}/chat-history?limit=50`,
);
});
diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx
index 592604a5..94538fbc 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="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
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:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
display: "block",
width: "100%",
@@ -444,6 +446,7 @@ export function FilterChips({
type="button"
aria-checked={on}
onClick={() => onChange(o.id)}
+ className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
display: "inline-flex",
alignItems: "center",
diff --git a/canvas/src/components/tabs/ActivityTab.tsx b/canvas/src/components/tabs/ActivityTab.tsx
index 092a58bc..360d5c03 100644
--- a/canvas/src/components/tabs/ActivityTab.tsx
+++ b/canvas/src/components/tabs/ActivityTab.tsx
@@ -139,7 +139,7 @@ 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/40 ${
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"
@@ -152,7 +152,7 @@ export function ActivityTab({ workspaceId }: Props) {
setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh}
- className={`text-[11px] px-1.5 py-0.5 rounded ${
+ className={`text-[11px] px-1.5 py-0.5 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid"
}`}
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
@@ -161,8 +161,9 @@ export function ActivityTab({ workspaceId }: Props) {
setTraceOpen(true)}
- className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30"
- title="View full conversation trace across all workspaces"
+ aria-label="Full trace"
+ className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
+ title="View full conversation trace"
>
Full Trace
diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx
index 1abc1f28..8a8071cb 100644
--- a/canvas/src/components/tabs/ChannelsTab.tsx
+++ b/canvas/src/components/tabs/ChannelsTab.tsx
@@ -331,8 +331,9 @@ export function ChannelsTab({ workspaceId }: Props) {
))}
setShowManualInput(!showManualInput)}
- className="text-[10px] text-accent hover:underline"
+ className="text-[10px] text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{showManualInput ? "hide manual input" : "edit manually"}
@@ -408,15 +409,16 @@ export function ChannelsTab({ workspaceId }: Props) {
handleTest(ch)}
disabled={testing === ch.id}
- className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50"
+ className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{testing === ch.id ? "Sent!" : "Test"}
handleToggle(ch)}
- className={`text-[10px] px-2 py-0.5 rounded transition ${
+ className={`text-[10px] px-2 py-0.5 rounded transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
ch.enabled
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid"
@@ -425,8 +427,9 @@ export function ChannelsTab({ workspaceId }: Props) {
{ch.enabled ? "On" : "Off"}
setPendingDelete(ch)}
- className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition"
+ className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
Remove
diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx
index d6a9b85c..261826d4 100644
--- a/canvas/src/components/tabs/ChatTab.tsx
+++ b/canvas/src/components/tabs/ChatTab.tsx
@@ -383,7 +383,8 @@ 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"
+ aria-label="Enable agent chat"
+ 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/40"
>
Enable
@@ -403,8 +404,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
Failed to load chat history: {history.loadError}
Retry
@@ -599,8 +601,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
{displayError}
{!isOnline && (
setConfirmRestart(true)}
- className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
+ className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
Restart
@@ -636,7 +639,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/40"
>
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"
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"
aria-expanded={showRegistry}
aria-controls="plugins-registry"
>
@@ -401,7 +401,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
handleUninstall(p.name)}
disabled={uninstalling === p.name}
- className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-bad hover:bg-red-900/30 disabled:opacity-30"
+ className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-bad hover:bg-red-900/30 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
{uninstalling === p.name ? "..." : "Remove"}
@@ -449,7 +449,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{installing === customSource.trim() ? "Installing..." : "Install"}
@@ -538,7 +538,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
handleInstall(p.name)}
disabled={installing === p.name}
- className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
+ className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
>
{installing === p.name ? "Installing..." : "Install"}
@@ -570,13 +570,13 @@ export function SkillsTab({ workspaceId, data }: Props) {
setPanelTab("config")}
- className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
+ className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
Open Config
setPanelTab("files")}
- className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
+ className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
Open Files
diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx
index b44ae1c0..1aedfdb5 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-cyan-400 ${
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 0d01a425..cb349d51 100644
--- a/canvas/src/components/tabs/chat/AttachmentViews.tsx
+++ b/canvas/src/components/tabs/chat/AttachmentViews.tsx
@@ -33,7 +33,7 @@ export function PendingAttachmentPill({