molecule-core/canvas/src/components/ConsoleModal.tsx
Hongming Wang 10f2b9f01c canvas/ConsoleModal: fix no-op hovers + add Copy success feedback
Four UIUX fixes for the EC2 console modal:

1. Copy and Close buttons had hover:bg-surface-card on TOP of the
   same base bg-surface-card — silent no-op hover. Lifted to
   surface-elevated + line-soft border, matching ConfirmDialog's
   Cancel pattern. The button visibly responds now.

2. Copy button silently succeeded — no toast, no animation, no UI
   feedback. Operators clicking it had no idea whether anything
   landed in the clipboard. Now fires showToast on resolve/reject
   so the action is observable.

3. × close button was ~10x16px (well under WCAG 2.5.5's 24x24).
   Bumped to w-6 h-6 with focus-visible ring + hover bg.

4. Added focus-visible:ring-accent/60 + ring-offset-surface to
   all three buttons so keyboard users see focus. Matches the
   semantic ring pattern used across the canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:58:31 -07:00

189 lines
7.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "@/lib/api";
import { showToast } from "@/components/Toaster";
interface Props {
workspaceId: string;
workspaceName?: string;
open: boolean;
onClose: () => void;
}
interface ConsoleResponse {
output: string;
instance_id?: string;
}
// ConsoleModal renders the EC2 serial console output for a workspace.
// Used by the "View Logs" button on failed/stuck workspaces so operators
// can see the actual cloud-init + runtime startup trace without SSH or
// AWS console access. The tenant platform proxies to the control plane;
// this component just consumes GET /workspaces/:id/console.
export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Props) {
const [output, setOutput] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setMounted(true);
}, []);
// Focus close button when modal opens
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => {
closeButtonRef.current?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open]);
useEffect(() => {
if (!open) return;
let ignore = false;
setLoading(true);
setError(null);
setOutput(null);
api
.get<ConsoleResponse>(`/workspaces/${workspaceId}/console`)
.then((data) => {
if (ignore) return;
setOutput(data.output || "");
})
.catch((e) => {
if (ignore) return;
// 501 = deployment without a control plane (local docker-compose).
// 404 = EC2 instance has been terminated. Match with word-boundary
// regex so a status code appearing inside an unrelated number
// ("15012") doesn't false-match.
const msg = e instanceof Error ? e.message : "Failed to load console output";
if (/\b501\b/.test(msg)) {
setError("Console output is only available on cloud (SaaS) deployments.");
} else if (/\b404\b/.test(msg)) {
setError("No EC2 instance found for this workspace — it may have been terminated.");
} else {
setError(msg);
}
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [open, workspaceId]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open || !mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
aria-labelledby="console-modal-title"
className="relative bg-surface border border-line rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-line">
<div>
<h3 id="console-modal-title" className="text-sm font-semibold text-ink">
EC2 console output
</h3>
{workspaceName && (
<div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
{workspaceName}
</div>
)}
</div>
<button
type="button"
ref={closeButtonRef}
onClick={onClose}
aria-label="Close"
// 24x24 touch target (was ~10x16, well under WCAG 2.5.5).
// Hover bg makes the area visible; focus-visible ring matches
// the rest of the canvas chrome.
className="w-6 h-6 inline-flex items-center justify-center rounded text-sm text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
>
</button>
</div>
<div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && (
<div className="text-[12px] text-ink-soft" data-testid="console-loading">
Loading console output
</div>
)}
{!loading && error && (
<div
role="alert"
className="text-[12px] text-warm bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
data-testid="console-error"
>
{error}
</div>
)}
{!loading && !error && output !== null && (
<pre
className="text-[11px] text-ink-mid font-mono whitespace-pre-wrap break-all leading-tight"
data-testid="console-output"
>
{output || "(console output is empty — the instance may still be booting)"}
</pre>
)}
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-line bg-surface-sunken/40">
{output && (
<button
type="button"
onClick={() => {
if (navigator.clipboard) {
// Add success feedback — without it, clicking Copy
// looked like a no-op since the previous hover bg was
// also a no-op (`hover:bg-surface-card` on top of the
// same base). Toast confirms the write actually fired.
navigator.clipboard
.writeText(output)
.then(() => showToast("Console output copied", "success"))
.catch(() => showToast("Copy failed", "error"));
} else {
showToast("Copy requires HTTPS — please select and copy manually", "info");
}
}}
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Copy
</button>
)}
<button
type="button"
onClick={onClose}
// Was hover:bg-surface-card (same as base silent no-op).
// Lift to surface-elevated so the button visibly responds,
// matching the Cancel button in ConfirmDialog.
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Close
</button>
</div>
</div>
</div>,
document.body,
);
}