diff --git a/canvas/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index 77f7ef6e..873ac07b 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -169,7 +169,17 @@ export default async function globalSetup(_config: FullConfig): Promise { orgID = row.id; return true; } - if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`); + if (row.instance_status === "failed") { + // Dump every diagnostic field the admin row carries — boot stage, + // last error, terraform/SSM state, etc. The bare slug message used + // to surface ZERO context, so triaging a failed provision meant + // re-running locally to repro. Now the failure log carries enough + // to point at the right subsystem (CP/AWS/SSM/runtime) without a + // second round-trip. + throw new Error( + `provision failed: ${slug} — admin-orgs row: ${JSON.stringify(row)}`, + ); + } return null; }, PROVISION_TIMEOUT_MS, @@ -249,7 +259,17 @@ export default async function globalSetup(_config: FullConfig): Promise { if (r.status !== 200) return null; if (r.body?.status === "online") return true; if (r.body?.status === "failed") { - throw new Error(`Workspace failed: ${r.body.last_sample_error || ""}`); + // last_sample_error is often empty when the failure happens before + // the agent emits a sample (e.g. boot crash, image pull error, + // missing PYTHONPATH, OpenAI quota at startup). Dumping the full + // body gives triage the boot_stage / last_error / image fields it + // needs without a second probe. Otherwise this propagates as a + // bare "Workspace failed: " — the exact useless message that + // sent #2632 to the issue tracker. + const detail = r.body.last_sample_error + ? r.body.last_sample_error + : `(no last_sample_error) full body: ${JSON.stringify(r.body)}`; + throw new Error(`Workspace failed: ${detail}`); } return null; }, diff --git a/canvas/src/components/ApprovalBanner.tsx b/canvas/src/components/ApprovalBanner.tsx index 5d36efc5..e9baa776 100644 --- a/canvas/src/components/ApprovalBanner.tsx +++ b/canvas/src/components/ApprovalBanner.tsx @@ -73,14 +73,19 @@ export function ApprovalBanner() { diff --git a/canvas/src/components/ConfirmDialog.tsx b/canvas/src/components/ConfirmDialog.tsx index 93961db4..75cacd70 100644 --- a/canvas/src/components/ConfirmDialog.tsx +++ b/canvas/src/components/ConfirmDialog.tsx @@ -91,12 +91,15 @@ export function ConfirmDialog({ if (!open || !mounted) return null; + // Hover goes DARKER, not lighter — lighter shades on white text drop + // contrast below AA on the accent and red ramps. Darker hovers stay + // readable in both light and dark themes. const confirmColors = confirmVariant === "danger" - ? "bg-red-600 hover:bg-red-500 text-white" + ? "bg-red-600 hover:bg-red-700 text-white" : confirmVariant === "warning" - ? "bg-amber-600 hover:bg-amber-500 text-white" - : "bg-accent-strong hover:bg-accent text-white"; + ? "bg-amber-600 hover:bg-amber-700 text-white" + : "bg-accent hover:bg-accent-strong text-white"; // Render via Portal so the fixed-position dialog escapes any containing block // (e.g. parents with transform, filter, will-change that break position:fixed). @@ -123,7 +126,7 @@ export function ConfirmDialog({ @@ -131,7 +134,7 @@ export function ConfirmDialog({ diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index ba63005b..af51c447 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -40,6 +40,22 @@ export interface ExternalConnectionInfo { // + inbound. Optional for backward compat with platforms that // haven't shipped PR #2413 yet. universal_mcp_snippet?: string; + // Hermes channel snippet — for operators whose external agent IS a + // hermes-agent session. Routes A2A traffic into the hermes gateway + // via the molecule-channel plugin (Molecule-AI/hermes-channel-molecule). + // Long-poll based (no tunnel) — same UX shape as the Claude Code + // channel tab. Gives hermes true push parity. Optional for backward + // compat with platforms that haven't shipped this PR yet. + hermes_channel_snippet?: string; + // Codex MCP config snippet — wires the molecule MCP server into + // ~/.codex/config.toml so codex agents can call platform tools. + // Outbound-tools-only today (codex's MCP client doesn't route + // notifications/*); push parity would need a separate bridge daemon. + codex_snippet?: string; + // OpenClaw MCP config snippet — wires molecule MCP + starts the + // openclaw gateway on loopback. Outbound-tools-only today; push + // parity on an external openclaw needs a sessions.steer bridge. + openclaw_snippet?: string; } interface Props { @@ -47,13 +63,21 @@ interface Props { onClose: () => void; } -type Tab = "python" | "curl" | "claude" | "mcp" | "fields"; +type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields"; export function ExternalConnectModal({ info, onClose }: Props) { - // Default to Claude Code when the platform offers it — that's the - // newest + simplest path (no tunnel needed). Falls back to Python - // for older platform builds that don't ship the snippet. - const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python"; + // Default to Universal MCP when the platform offers it — runtime- + // agnostic outbound tool path that works for any MCP-aware runtime + // (Claude Code, hermes, codex, etc.) and lets operators inspect the + // primitives before picking a runtime-specific tab. Python SDK is + // the fallback for platforms predating the universal_mcp_snippet + // field. Pre-2026-05-03 the default was "claude" (Claude Code first) + // but operators using non-Claude runtimes opened to a tab they had + // to skip past — universal MCP works for everyone as a starting + // point and the runtime-specific tabs are still one click away. + const initialTab: Tab = info?.universal_mcp_snippet + ? "mcp" + : "python"; const [tab, setTab] = useState(initialTab); const [copiedKey, setCopiedKey] = useState(null); @@ -108,6 +132,24 @@ export function ExternalConnectModal({ info, onClose }: Props) { 'MOLECULE_WORKSPACE_TOKEN=""', `MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`, ); + // Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var + // name as Universal MCP). Stamp the auth_token in so the operator's + // copy-paste is fully ready-to-run. + const filledHermes = info.hermes_channel_snippet?.replace( + 'MOLECULE_WORKSPACE_TOKEN=""', + `MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`, + ); + // Codex + OpenClaw snippets carry the placeholder inside the + // generated config block (TOML / JSON respectively). Stamp the + // token in so the copy-paste is one less manual edit. + const filledCodex = info.codex_snippet?.replace( + 'MOLECULE_WORKSPACE_TOKEN = ""', + `MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`, + ); + const filledOpenClaw = info.openclaw_snippet?.replace( + 'WORKSPACE_TOKEN=""', + `WORKSPACE_TOKEN="${info.auth_token}"`, + ); return ( !o && onClose()}> @@ -135,10 +177,18 @@ export function ExternalConnectModal({ info, onClose }: Props) { // SDK second (full register+heartbeat+inbound); Universal // MCP third (any MCP-aware runtime, outbound-only); curl // for one-shot register; Fields for raw values. + // Tab order: Universal MCP first (default, runtime- + // agnostic primitives), then runtime-specific channel/ + // SDK tabs, then curl + Fields. Each runtime tab only + // appears when the platform supplies the snippet — no + // dead "tab missing snippet" UX. const tabs: Tab[] = []; - if (filledChannel) tabs.push("claude"); - tabs.push("python"); if (filledUniversalMcp) tabs.push("mcp"); + tabs.push("python"); + if (filledChannel) tabs.push("claude"); + if (filledHermes) tabs.push("hermes"); + if (filledCodex) tabs.push("codex"); + if (filledOpenClaw) tabs.push("openclaw"); tabs.push("curl", "fields"); return tabs; })().map((t) => ( @@ -156,6 +206,12 @@ export function ExternalConnectModal({ info, onClose }: Props) { > {t === "claude" ? "Claude Code" + : t === "hermes" + ? "Hermes" + : t === "codex" + ? "Codex" + : t === "openclaw" + ? "OpenClaw" : t === "python" ? "Python SDK" : t === "mcp" @@ -205,6 +261,33 @@ export function ExternalConnectModal({ info, onClose }: Props) { onCopy={() => copy(filledUniversalMcp, "mcp")} /> )} + {tab === "hermes" && filledHermes && ( + copy(filledHermes, "hermes")} + /> + )} + {tab === "codex" && filledCodex && ( + copy(filledCodex, "codex")} + /> + )} + {tab === "openclaw" && filledOpenClaw && ( + copy(filledOpenClaw, "openclaw")} + /> + )} {tab === "fields" && (
copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} /> diff --git a/canvas/src/components/Toaster.tsx b/canvas/src/components/Toaster.tsx index 13396845..f8767fb3 100644 --- a/canvas/src/components/Toaster.tsx +++ b/canvas/src/components/Toaster.tsx @@ -38,6 +38,18 @@ export function Toaster() { }; }, []); + // Esc dismisses the newest toast — keyboard parity with the × button. + // Errors never auto-expire, so without this a keyboard-only user has to + // tab through the entire app to reach the dismiss button on a stuck error. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1))); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + const toastCls = (type: Toast["type"]) => `flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${ type === "success" @@ -47,6 +59,17 @@ export function Toaster() { : "bg-surface-sunken/90 border border-line/40 text-ink" }`; + // Success/error toasts are intentionally dark in both themes (high-vis). + // Info uses the semantic surface that flips with theme — so the dismiss + // button needs a tint that stays visible on a light bg in light mode. + const dismissCls = (type: Toast["type"]) => { + const base = + "ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0"; + return type === "info" + ? `${base} hover:bg-ink/10 focus-visible:ring-accent/60` + : `${base} hover:bg-white/15 focus-visible:ring-white/70`; + }; + const pos = "fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center"; @@ -66,7 +89,7 @@ export function Toaster() { type="button" onClick={() => dismiss(toast.id)} aria-label="Dismiss notification" - className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0" + className={dismissCls(toast.type)} > × @@ -94,7 +117,7 @@ export function Toaster() { type="button" onClick={() => dismiss(toast.id)} aria-label="Dismiss notification" - className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0" + className={dismissCls(toast.type)} > × diff --git a/canvas/src/components/Tooltip.tsx b/canvas/src/components/Tooltip.tsx index a1ce074c..d694ec28 100644 --- a/canvas/src/components/Tooltip.tsx +++ b/canvas/src/components/Tooltip.tsx @@ -22,6 +22,24 @@ export function Tooltip({ text, children }: Props) { useEffect(() => () => clearTimeout(timerRef.current), []); + // WCAG 1.4.13 (Content on Hover or Focus) — Dismissible: a mechanism + // is available to dismiss the additional content WITHOUT moving + // pointer hover or keyboard focus. Esc dismisses while the trigger + // stays focused/hovered, so a screen-magnifier user can read what + // the tooltip was covering without losing their place. + useEffect(() => { + if (!show) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + clearTimeout(timerRef.current); + setShow(false); + } + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [show]); + const enter = useCallback(() => { timerRef.current = setTimeout(() => { if (triggerRef.current) { diff --git a/canvas/src/components/__tests__/Toaster.test.tsx b/canvas/src/components/__tests__/Toaster.test.tsx new file mode 100644 index 00000000..6f6f35ed --- /dev/null +++ b/canvas/src/components/__tests__/Toaster.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { Toaster, showToast } from "../Toaster"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("Toaster keyboard a11y", () => { + it("Esc dismisses the most recent toast", () => { + render(); + act(() => { + showToast("first", "info"); + showToast("second", "info"); + }); + expect(screen.getByText("first")).toBeTruthy(); + expect(screen.getByText("second")).toBeTruthy(); + + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + expect(screen.queryByText("second")).toBeNull(); + expect(screen.getByText("first")).toBeTruthy(); + }); + + it("Esc dismisses persistent error toasts", () => { + render(); + act(() => { + showToast("boom", "error"); + }); + expect(screen.getByText("boom")).toBeTruthy(); + + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + expect(screen.queryByText("boom")).toBeNull(); + }); + + it("Esc with no toasts is a no-op", () => { + render(); + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + // no throw, nothing rendered + expect(screen.queryAllByRole("button", { name: "Dismiss notification" })).toHaveLength(0); + }); + + it("dismiss button has accessible label and is keyboard reachable", () => { + render(); + act(() => { + showToast("hi", "info"); + }); + const btn = screen.getByRole("button", { name: "Dismiss notification" }); + expect(btn).toBeTruthy(); + // Native