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:
parent
c7fda85a3e
commit
7f0cb106bd
18 changed files with 552 additions and 118 deletions
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue