fix(canvas): 5 UX polish fixes — error handling, a11y, loading state

1. ScheduleTab + ChannelsTab: wrap toggle/delete in try/catch with
   error feedback (was silently swallowing API failures)
2. MemoryTab: "+Add" button now auto-expands Advanced section
3. SidePanel: keyboard-navigated tabs scroll into view
4. TracesTab: emoji aria-hidden, env-var hint in <details>
5. page.tsx: show Spinner while hydrating instead of flash of EmptyState

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-16 21:39:44 -07:00
parent 1af06a669b
commit c06ac8aa8a
6 changed files with 61 additions and 19 deletions

View File

@ -1,9 +1,10 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Canvas } from "@/components/Canvas";
import { Legend } from "@/components/Legend";
import { CommunicationOverlay } from "@/components/CommunicationOverlay";
import { Spinner } from "@/components/Spinner";
import { connectSocket, disconnectSocket } from "@/store/socket";
import { useCanvasStore } from "@/store/canvas";
import { api } from "@/lib/api";
@ -12,6 +13,7 @@ import type { WorkspaceData } from "@/store/socket";
export default function Home() {
const hydrationError = useCanvasStore((s) => s.hydrationError);
const setHydrationError = useCanvasStore((s) => s.setHydrationError);
const [hydrating, setHydrating] = useState(true);
useEffect(() => {
connectSocket();
@ -31,6 +33,8 @@ export default function Home() {
useCanvasStore.getState().setHydrationError(
err instanceof Error && err.message ? err.message : "Failed to load canvas"
);
}).finally(() => {
setHydrating(false);
});
return () => {
@ -38,6 +42,17 @@ export default function Home() {
};
}, []);
if (hydrating) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950">
<div className="flex flex-col items-center gap-3">
<Spinner size="lg" />
<span className="text-xs text-zinc-500">Loading canvas...</span>
</div>
</div>
);
}
return (
<>
<Canvas />

View File

@ -173,7 +173,7 @@ export function SidePanel() {
else if (e.key === "End") { e.preventDefault(); next = TABS.length - 1; }
if (next !== null) {
setPanelTab(TABS[next].id);
requestAnimationFrame(() => { document.getElementById(`tab-${TABS[next!].id}`)?.focus(); });
requestAnimationFrame(() => { const el = document.getElementById(`tab-${TABS[next!].id}`); el?.focus(); el?.scrollIntoView({ block: "nearest", inline: "nearest" }); });
}
}}
>

View File

@ -141,19 +141,29 @@ export function ChannelsTab({ workspaceId }: Props) {
}
};
const [error, setError] = useState("");
const handleToggle = async (ch: Channel) => {
await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, {
enabled: !ch.enabled,
});
load();
try {
await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, {
enabled: !ch.enabled,
});
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to toggle channel");
}
};
const confirmDelete = async () => {
if (!pendingDelete) return;
const ch = pendingDelete;
setPendingDelete(null);
await api.del(`/workspaces/${workspaceId}/channels/${ch.id}`);
load();
try {
await api.del(`/workspaces/${workspaceId}/channels/${ch.id}`);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to delete channel");
}
};
const handleTest = async (ch: Channel) => {
@ -188,6 +198,12 @@ export function ChannelsTab({ workspaceId }: Props) {
</button>
</div>
{error && (
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
{error}
</div>
)}
{/* Create form */}
{showForm && (
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">

View File

@ -219,7 +219,7 @@ export function MemoryTab({ workspaceId }: Props) {
Refresh
</button>
<button
onClick={() => setShowAdd(!showAdd)}
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
>
+ Add

View File

@ -126,15 +126,23 @@ export function ScheduleTab({ workspaceId }: Props) {
if (!pendingDelete) return;
const { id } = pendingDelete;
setPendingDelete(null);
await api.del(`/workspaces/${workspaceId}/schedules/${id}`);
fetchSchedules();
try {
await api.del(`/workspaces/${workspaceId}/schedules/${id}`);
fetchSchedules();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to delete schedule");
}
};
const handleToggle = async (sched: Schedule) => {
await api.patch(`/workspaces/${workspaceId}/schedules/${sched.id}`, {
enabled: !sched.enabled,
});
fetchSchedules();
try {
await api.patch(`/workspaces/${workspaceId}/schedules/${sched.id}`, {
enabled: !sched.enabled,
});
fetchSchedules();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to toggle schedule");
}
};
const handleEdit = (sched: Schedule) => {

View File

@ -68,11 +68,14 @@ export function TracesTab({ workspaceId }: Props) {
{traces.length === 0 && !error ? (
<div className="text-center py-8">
<div className="text-2xl opacity-20 mb-2">📊</div>
<div className="text-2xl opacity-20 mb-2" aria-hidden="true">--</div>
<p className="text-xs text-zinc-600">No traces yet</p>
<p className="text-[10px] text-zinc-700 mt-1">
Set LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY to enable tracing
</p>
<details className="mt-2 text-[10px] text-zinc-700">
<summary className="cursor-pointer text-zinc-500 hover:text-zinc-400">How to enable tracing</summary>
<p className="mt-1">
Set <code className="font-mono text-zinc-400">LANGFUSE_HOST</code>, <code className="font-mono text-zinc-400">LANGFUSE_PUBLIC_KEY</code>, <code className="font-mono text-zinc-400">LANGFUSE_SECRET_KEY</code> as workspace secrets to enable tracing.
</p>
</details>
</div>
) : (
<div className="space-y-1">