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:
commit
ded7dc777c
@ -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()}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user