feat(web): add skeleton loading, error toasts, and confirmation dialogs

- Replace all "Loading..." text with structured skeleton screens
  (Issue Detail, Agents, Skills, Runtimes, Tokens, Usage)
- Add toast.error for all API failures that were previously silent
  (Agents CRUD, Skills CRUD, workspace store, issue/inbox stores,
   timeline/reactions/subscribers hooks, agent-live-card)
- Add toast.success for mutations (agent update/delete, skill CRUD)
- Add confirmation dialogs for destructive actions
  (comment delete, token revoke)
- Add empty states for Issues and My Issues pages
- Fix hydrateWorkspace resilience: each request catches independently
  so partial failures don't block workspace entry
- Fix React key warning in issue-detail timeline rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-02 16:46:56 +08:00
parent c7fda85a3e
commit 7f0cb106bd
18 changed files with 552 additions and 118 deletions

View file

@ -7,6 +7,7 @@ import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
import type { AgentTask } from "@/shared/types/agent";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
@ -123,10 +124,10 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
setItems(timeline);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
}
}).catch(() => {});
}).catch(console.error);
}
}
}).catch(() => {});
}).catch(console.error);
return () => { cancelled = true; };
}, [issueId]);
@ -207,7 +208,7 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
setItems([]);
seenSeqs.current.clear();
}
}).catch(() => {});
}).catch(console.error);
}, [issueId, activeTask]),
);
@ -238,7 +239,8 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
setCancelling(true);
try {
await api.cancelTask(issueId, activeTask.id);
} catch {
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
setCancelling(false);
}
}, [activeTask, issueId, cancelling]);
@ -321,7 +323,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
const [open, setOpen] = useState(false);
useEffect(() => {
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]);
// Refresh when a task completes
@ -330,7 +332,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
@ -339,7 +341,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
@ -349,7 +351,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskCancelledPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
@ -382,7 +384,10 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
if (items !== null) return; // already loaded
api.listTaskMessages(task.id).then((msgs) => {
setItems(buildTimeline(msgs));
}).catch(() => setItems([]));
}).catch((e) => {
console.error(e);
setItems([]);
});
}, [task.id, items]);
useEffect(() => {

View file

@ -55,7 +55,7 @@ export function BatchActionToolbar() {
toast.error("Failed to update issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
} finally {
setLoading(false);
}
@ -74,7 +74,7 @@ export function BatchActionToolbar() {
toast.error("Failed to delete issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
} finally {
setLoading(false);
setDeleteOpen(false);

View file

@ -13,6 +13,16 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { ReactionBar } from "@/components/common/reaction-bar";
@ -44,6 +54,43 @@ interface CommentCardProps {
highlightedCommentId?: string | null;
}
// ---------------------------------------------------------------------------
// Shared delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteCommentDialog({
open,
onOpenChange,
onConfirm,
hasReplies,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
hasReplies?: boolean;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete comment</AlertDialogTitle>
<AlertDialogDescription>
{hasReplies
? "This comment and all its replies will be permanently deleted. This cannot be undone."
: "This comment will be permanently deleted. This cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
// Single comment row (used for both parent and replies within the same Card)
// ---------------------------------------------------------------------------
@ -71,6 +118,7 @@ function CommentRow({
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
@ -152,7 +200,7 @@ function CommentRow({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@ -160,6 +208,11 @@ function CommentRow({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
)}
</div>
@ -234,6 +287,7 @@ function CommentCard({
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
@ -347,7 +401,7 @@ function CommentCard({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@ -355,6 +409,12 @@ function CommentCard({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
hasReplies
/>
</div>
)}
</div>

View file

@ -19,6 +19,7 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
@ -214,23 +215,26 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
.then((iss) => {
useIssueStore.getState().addIssue(iss);
})
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load issue");
})
.finally(() => setIssueLoading(false));
}, [id, !!issue]);
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, submitting, submitComment, submitReply,
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions,
reactions: issueReactions, loading: reactionsLoading,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
const loading = issueLoading;
@ -305,8 +309,51 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (loading) {
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-1 min-h-0">
{/* Content skeleton */}
<div className="flex-1 p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
</div>
</div>
{/* Sidebar skeleton */}
<div className="w-64 border-l p-4 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-24" />
</div>
))}
<Skeleton className="h-px w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-28" />
</div>
))}
</div>
</div>
</div>
);
}
@ -606,11 +653,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
/>
<div className="flex items-center gap-1 mt-3">
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
/>
{reactionsLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-7 w-14 rounded-full" />
<Skeleton className="h-7 w-14 rounded-full" />
</div>
) : (
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
/>
)}
<FileUploadButton
size="sm"
onUpload={handleDescriptionUpload}
@ -627,6 +681,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<h2 className="text-base font-semibold">Activity</h2>
</div>
<div className="flex items-center gap-2">
{subscribersLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-16" />
<div className="flex -space-x-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
</div>
) : (<>
<button
onClick={handleToggleSubscribe}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@ -704,6 +767,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
@ -722,7 +786,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Timeline entries */}
<div className="mt-4 flex flex-col gap-3">
{(() => {
{timelineLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 px-4">
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
))}
</div>
) : (() => {
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
const repliesByParent = new Map<string, TimelineEntry[]>();
for (const e of timeline) {
@ -773,9 +849,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (group.type === "comment") {
const entry = group.entries[0]!;
return (
<div id={`comment-${entry.id}`}>
<div key={entry.id} id={`comment-${entry.id}`}>
<CommentCard
key={entry.id}
issueId={id}
entry={entry}
allReplies={repliesByParent}

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { toast } from "sonner";
import { ChevronRight } from "lucide-react";
import { ChevronRight, ListTodo } from "lucide-react";
import type { IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton";
import { useIssueStore } from "@/features/issues/store";
@ -84,7 +84,7 @@ export function IssuesPage() {
toast.error("Failed to move issue");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
});
},
[]
@ -131,19 +131,27 @@ export function IssuesPage() {
{/* Content: scrollable */}
<ViewStoreProvider store={useIssueViewStore}>
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
{scopedIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">No issues yet</p>
<p className="text-xs">Create an issue to get started.</p>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
)}
{viewMode === "list" && <BatchActionToolbar />}
</ViewStoreProvider>
</div>