Merge pull request #888 from Molecule-AI/fix/canvas-a11y-sidepanel-resize-keyboard

fix(canvas): a11y — SidePanel keyboard resize, MemoryEntryRow aria-controls, contrast + ChatTab error banner
This commit is contained in:
molecule-ai[bot] 2026-04-18 01:20:02 +00:00 committed by GitHub
commit ded7dc777c
4 changed files with 80 additions and 10 deletions

View File

@ -414,6 +414,7 @@ function MemoryEntryRow({
onCancelEdit,
onDelete,
}: MemoryEntryRowProps) {
const bodyId = `memory-body-${entry.key.replace(/\s+/g, "-")}`;
return (
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
{/* Header row — click to expand/collapse */}
@ -421,6 +422,7 @@ function MemoryEntryRow({
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
onClick={onToggle}
aria-expanded={isExpanded}
aria-controls={bodyId}
>
<span className="text-[10px] font-mono text-blue-400 truncate flex-1 min-w-0">
{entry.key}
@ -455,7 +457,12 @@ function MemoryEntryRow({
{/* Expanded body */}
{isExpanded && (
<div className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2">
<div
id={bodyId}
role="region"
aria-label={`Details for ${entry.key}`}
className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2"
>
{entry.expires_at && (
<p className="text-[9px] text-zinc-500">
Expires: {new Date(entry.expires_at).toLocaleString()}

View File

@ -23,6 +23,7 @@ import { summarizeWorkspaceCapabilities } from "@/store/canvas";
const SIDEPANEL_WIDTH_KEY = "molecule:sidepanel-width";
const SIDEPANEL_DEFAULT_WIDTH = 480;
const SIDEPANEL_MIN_WIDTH = 320;
const SIDEPANEL_MAX_WIDTH = 800;
const TABS: { id: PanelTab; label: string; icon: string }[] = [
{ id: "chat", label: "Chat", icon: "◈" },
@ -72,6 +73,29 @@ export function SidePanel() {
document.body.style.userSelect = "none";
}, [width]);
const onResizeKeyDown = useCallback((e: React.KeyboardEvent) => {
const STEP = 16;
let newWidth: number | null = null;
if (e.key === "ArrowLeft") {
e.preventDefault();
newWidth = Math.min(width + STEP, SIDEPANEL_MAX_WIDTH);
} else if (e.key === "ArrowRight") {
e.preventDefault();
newWidth = Math.max(width - STEP, SIDEPANEL_MIN_WIDTH);
} else if (e.key === "Home") {
e.preventDefault();
newWidth = SIDEPANEL_MIN_WIDTH;
} else if (e.key === "End") {
e.preventDefault();
newWidth = SIDEPANEL_MAX_WIDTH;
}
if (newWidth !== null) {
setWidth(newWidth);
widthRef.current = newWidth;
localStorage.setItem(SIDEPANEL_WIDTH_KEY, String(newWidth));
}
}, [width]);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
@ -111,8 +135,16 @@ export function SidePanel() {
>
{/* Resize handle */}
<div
role="separator"
aria-label="Resize workspace panel"
aria-valuenow={width}
aria-valuemin={SIDEPANEL_MIN_WIDTH}
aria-valuemax={SIDEPANEL_MAX_WIDTH}
aria-orientation="vertical"
tabIndex={0}
onMouseDown={onMouseDown}
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10"
onKeyDown={onResizeKeyDown}
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset"
/>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800/40 bg-zinc-900/30">

View File

@ -256,8 +256,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{/* Degraded error preview */}
{data.status === "degraded" && data.lastSampleError && (
<div
className="text-[10px] text-amber-300/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
title={data.lastSampleError}
role="status"
className="text-[10px] text-amber-400 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
aria-label={`Error: ${data.lastSampleError}`}
>
{data.lastSampleError}
</div>

View File

@ -55,7 +55,7 @@ function extractReplyText(resp: A2AResponse): string {
* Load chat history from the activity_logs database via the platform API.
* Uses source=canvas to only get user-initiated messages (not agent-to-agent).
*/
async function loadMessagesFromDB(workspaceId: string): Promise<ChatMessage[]> {
async function loadMessagesFromDB(workspaceId: string): Promise<{ messages: ChatMessage[]; error: string | null }> {
try {
const activities = await api.get<Array<{
activity_type: string;
@ -83,9 +83,12 @@ async function loadMessagesFromDB(workspaceId: string): Promise<ChatMessage[]> {
}
}
}
return messages;
} catch {
return [];
return { messages, error: null };
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
};
}
}
@ -162,6 +165,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
const [thinkingElapsed, setThinkingElapsed] = useState(0);
const [activityLog, setActivityLog] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const currentTaskRef = useRef(data.currentTask);
const sendingFromAPIRef = useRef(false);
const [agentReachable, setAgentReachable] = useState(false);
@ -172,8 +176,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
// Load chat history from database on mount
useEffect(() => {
setLoading(true);
loadMessagesFromDB(workspaceId).then((msgs) => {
setLoadError(null);
loadMessagesFromDB(workspaceId).then(({ messages: msgs, error: fetchErr }) => {
setMessages(msgs);
setLoadError(fetchErr);
setLoading(false);
});
}, [workspaceId]);
@ -355,7 +361,31 @@ function MyChatPanel({ workspaceId, data }: Props) {
{loading && (
<div className="text-xs text-zinc-500 text-center py-4">Loading chat history...</div>
)}
{!loading && messages.length === 0 && (
{!loading && loadError !== null && messages.length === 0 && (
<div
role="alert"
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
>
<p className="text-[11px] text-red-400 mb-1.5">
Failed to load chat history: {loadError}
</p>
<button
onClick={() => {
setLoading(true);
setLoadError(null);
loadMessagesFromDB(workspaceId).then(({ messages: msgs, error: fetchErr }) => {
setMessages(msgs);
setLoadError(fetchErr);
setLoading(false);
});
}}
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-red-300 hover:bg-red-700/50 transition-colors"
>
Retry
</button>
</div>
)}
{!loading && loadError === null && messages.length === 0 && (
<div className="text-xs text-zinc-500 text-center py-8">
No messages yet. Send a message to start chatting with this agent.
</div>