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 }) => ( -
    - {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 }) => ( - - {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 })} - @@ -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 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) { ))} @@ -408,15 +409,16 @@ export function ChannelsTab({ workspaceId }: Props) {
    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}

    @@ -599,8 +601,9 @@ function MyChatPanel({ workspaceId, data }: Props) { {displayError} {!isOnline && ( @@ -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" >
    @@ -56,7 +88,7 @@ export function FileEditor({ @@ -64,7 +96,7 @@ export function FileEditor({ @@ -75,11 +107,42 @@ export function FileEditor({ {/* Editor area */} {loadingFile ? (
    Loading...
    + ) : isSecretShapeDenied ? ( + // Files API refused to surface this file's bytes because its + // path or content matched a credential regex + // (workspace-server/internal/secrets, internal#425 Phase 2b). + // We render a placeholder INSTEAD OF the textarea so the + // matched bytes never enter the DOM. Clipboard / view-source + // / element-inspector all see the placeholder, not the + // credential. +
    +
    +
    🛡️
    +

    + {SECRET_SHAPE_DENIED_MARKER} +

    +

    + The platform refused to surface this file because its + path or content matched a credential-shape pattern. + The bytes never left the workspace container. +

    +

    + If this is a false positive (test fixture, docs example, + or content that happens to share a credential's shape), + rename the file or adjust the content via the workspace + terminal so the regex no longer matches, then refresh. +

    +
    +
    ) : (