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:
parent
1af06a669b
commit
c06ac8aa8a
@ -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 />
|
||||
|
||||
@ -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" }); });
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user