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

@ -70,6 +70,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
@ -380,6 +381,8 @@ function InstructionsTab({
setSaving(true);
try {
await onSave(value);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@ -446,6 +449,8 @@ function SkillsTab({
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
await refreshAgents();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add skill");
} finally {
setSaving(false);
setShowPicker(false);
@ -458,6 +463,8 @@ function SkillsTab({
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
await api.setAgentSkills(agent.id, { skill_ids: newIds });
await refreshAgents();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
} finally {
setSaving(false);
}
@ -701,6 +708,8 @@ function ToolsTab({
setSaving(true);
try {
await onSave(tools);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@ -845,6 +854,8 @@ function TriggersTab({
setSaving(true);
try {
await onSave(triggers);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@ -1050,8 +1061,17 @@ function TasksTab({ agent }: { agent: Agent }) {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Loading tasks...
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border px-4 py-3">
<Skeleton className="h-4 w-4 rounded shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/3" />
</div>
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
);
}
@ -1522,25 +1542,68 @@ export default function AgentsPage() {
};
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
await api.updateAgent(id, data as UpdateAgentRequest);
await refreshAgents();
try {
await api.updateAgent(id, data as UpdateAgentRequest);
await refreshAgents();
toast.success("Agent updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update agent");
throw e;
}
};
const handleDelete = async (id: string) => {
await api.deleteAgent(id);
if (selectedId === id) {
const remaining = agents.filter((a) => a.id !== id);
setSelectedId(remaining[0]?.id ?? "");
try {
await api.deleteAgent(id);
if (selectedId === id) {
const remaining = agents.filter((a) => a.id !== id);
setSelectedId(remaining[0]?.id ?? "");
}
await refreshAgents();
toast.success("Agent deleted");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete agent");
}
await refreshAgents();
};
const selected = agents.find((a) => a.id === selectedId) ?? null;
if (isLoading) {
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">
{/* List skeleton */}
<div className="w-72 border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-6 w-6 rounded" />
</div>
<div className="divide-y">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Detail skeleton */}
<div className="flex-1 p-6 space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
<div className="space-y-3">
<Skeleton className="h-8 w-full rounded-lg" />
<Skeleton className="h-8 w-full rounded-lg" />
<Skeleton className="h-8 w-3/4 rounded-lg" />
</div>
</div>
</div>
);
}

View file

@ -22,6 +22,17 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { api } from "@/shared/api";
@ -33,13 +44,17 @@ export function TokensTab() {
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
const [tokensLoading, setTokensLoading] = useState(true);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch {
// ignore — tokens section simply stays empty
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load tokens");
} finally {
setTokensLoading(false);
}
}, []);
@ -117,7 +132,21 @@ export function TokensTab() {
</CardContent>
</Card>
{tokens.length > 0 && (
{tokensLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-3">
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-8 rounded" />
</CardContent>
</Card>
))}
</div>
) : tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<Card key={t.id}>
@ -135,7 +164,7 @@ export function TokensTab() {
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevokeToken(t.id)}
onClick={() => setRevokeConfirmId(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
@ -152,6 +181,29 @@ export function TokensTab() {
)}
</section>
<AlertDialog open={!!revokeConfirmId} onOpenChange={(v) => { if (!v) setRevokeConfirmId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke token</AlertDialogTitle>
<AlertDialogDescription>
This token will be permanently revoked and can no longer be used. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={async () => {
if (revokeConfirmId) await handleRevokeToken(revokeConfirmId);
setRevokeConfirmId(null);
}}
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>

View file

@ -2,6 +2,7 @@
import { create } from "zustand";
import type { InboxItem, IssueStatus } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@ -72,6 +73,7 @@ export const useInboxStore = create<InboxState>((set, get) => ({
set({ items: data, loading: false });
} catch (err) {
logger.error("fetch failed", err);
toast.error("Failed to load inbox");
if (isInitialLoad) set({ loading: false });
}
},

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>

View file

@ -7,8 +7,8 @@ import type {
IssueReactionRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueReactions(issueId: string, userId?: string) {
const [reactions, setReactions] = useState<IssueReaction[]>([]);
@ -21,7 +21,10 @@ export function useIssueReactions(issueId: string, userId?: string) {
api
.getIssue(issueId)
.then((iss) => setReactions(iss.reactions ?? []))
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load reactions");
})
.finally(() => setLoading(false));
}, [issueId]);

View file

@ -7,8 +7,8 @@ import type {
SubscriberRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueSubscribers(issueId: string, userId?: string) {
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
@ -21,7 +21,10 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
api
.listIssueSubscribers(issueId)
.then((subs) => setSubscribers(subs))
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load subscribers");
})
.finally(() => setLoading(false));
}, [issueId]);

View file

@ -41,7 +41,10 @@ export function useIssueTimeline(issueId: string, userId?: string) {
api
.listTimeline(issueId)
.then((entries) => setTimeline(entries))
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load activity");
})
.finally(() => setLoading(false));
}, [issueId]);

View file

@ -2,6 +2,7 @@
import { create } from "zustand";
import type { Issue } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@ -34,6 +35,7 @@ export const useIssueStore = create<IssueState>((set, get) => ({
set({ issues: res.issues, loading: false });
} catch (err) {
logger.error("fetch failed", err);
toast.error("Failed to load issues");
if (isInitialLoad) set({ loading: false });
}
},

View file

@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { useStore } from "zustand";
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 { useAuthStore } from "@/features/auth";
@ -124,7 +124,7 @@ export function MyIssuesPage() {
toast.error("Failed to move issue");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
});
},
[],
@ -171,19 +171,27 @@ export function MyIssuesPage() {
{/* Content: scrollable */}
<ViewStoreProvider store={myIssuesViewStore}>
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={myIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
{myIssues.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 assigned to you</p>
<p className="text-xs">Issues you create or are assigned to will appear here.</p>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={myIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
)}
{viewMode === "list" && <BatchActionToolbar />}
</ViewStoreProvider>
</div>

View file

@ -145,8 +145,8 @@ export function useRealtimeSync(ws: WSClient | null) {
useWorkspaceStore.getState().refreshMembers(),
useWorkspaceStore.getState().refreshSkills(),
]);
} catch {
// Silently fail; next reconnect will retry
} catch (e) {
logger.error("reconnect refetch failed", e);
}
});

View file

@ -8,6 +8,7 @@ import {
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
@ -44,8 +45,36 @@ export default function RuntimesPage() {
if (isLoading || fetching) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex flex-1 min-h-0">
{/* List skeleton */}
<div className="w-72 border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<Skeleton className="h-4 w-20" />
</div>
<div className="divide-y">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-5 w-5 rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</div>
{/* Detail skeleton */}
<div className="flex-1 p-6 space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
</div>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { BarChart3 } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import type { RuntimeUsage } from "@/shared/types";
import { api } from "@/shared/api";
import { formatTokens, estimateCost, aggregateByDate } from "../utils";
@ -38,7 +39,22 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) {
if (loading) {
return (
<div className="text-xs text-muted-foreground">Loading usage...</div>
<div className="space-y-4">
<div className="flex items-center gap-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-12 rounded-md" />
))}
</div>
<div className="grid grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 rounded-lg" />
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Skeleton className="h-64 rounded-lg" />
<Skeleton className="h-64 rounded-lg" />
</div>
</div>
);
}

View file

@ -30,6 +30,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
@ -352,6 +354,7 @@ function SkillDetail({
);
const [selectedPath, setSelectedPath] = useState(SKILL_MD);
const [saving, setSaving] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [showAddFile, setShowAddFile] = useState(false);
@ -365,10 +368,13 @@ function SkillDetail({
// Fetch full skill (with files) on selection change
useEffect(() => {
setSelectedPath(SKILL_MD);
setLoadingFiles(true);
api.getSkill(skill.id).then((full) => {
useWorkspaceStore.getState().upsertSkill(full);
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
});
}).catch((e) => {
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
}).finally(() => setLoadingFiles(false));
}, [skill.id]);
// Build the virtual file map
@ -392,6 +398,8 @@ function SkillDetail({
content,
files: files.filter((f) => f.path.trim()),
});
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@ -514,22 +522,40 @@ function SkillDetail({
</div>
</div>
<div className="flex-1 overflow-y-auto">
<FileTree
filePaths={filePaths}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
{loadingFiles ? (
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<FileTree
filePaths={filePaths}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
)}
</div>
</div>
{/* File viewer */}
<div className="flex-1 min-w-0">
{loadingFiles ? (
<div className="p-4 space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<FileViewer
key={selectedPath}
path={selectedPath}
content={selectedContent}
onChange={handleFileContentChange}
/>
)}
</div>
</div>
@ -604,34 +630,83 @@ export default function SkillsPage() {
const skill = await api.createSkill(data);
upsertSkill(skill);
setSelectedId(skill.id);
toast.success("Skill created");
};
const handleImport = async (url: string) => {
const skill = await api.importSkill({ url });
upsertSkill(skill);
setSelectedId(skill.id);
toast.success("Skill imported");
};
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
try {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
toast.success("Skill saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save skill");
throw e;
}
};
const handleDelete = async (id: string) => {
await api.deleteSkill(id);
if (selectedId === id) {
const remaining = skills.filter((s) => s.id !== id);
setSelectedId(remaining[0]?.id ?? "");
try {
await api.deleteSkill(id);
if (selectedId === id) {
const remaining = skills.filter((s) => s.id !== id);
setSelectedId(remaining[0]?.id ?? "");
}
removeSkill(id);
toast.success("Skill deleted");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
}
removeSkill(id);
};
const selected = skills.find((s) => s.id === selectedId) ?? null;
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex flex-1 min-h-0">
{/* List skeleton */}
<div className="w-72 border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-6 w-6 rounded" />
</div>
<div className="divide-y">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-40" />
</div>
</div>
))}
</div>
</div>
{/* Detail skeleton */}
<div className="flex-1 flex flex-col">
<div className="flex items-center gap-3 border-b px-4 py-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-40" />
<Skeleton className="h-8 w-56" />
</div>
<div className="flex flex-1 min-h-0">
<div className="w-48 border-r p-3 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="flex-1 p-4 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
</div>
);
}

View file

@ -5,6 +5,7 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useRuntimeStore } from "@/features/runtimes";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@ -76,11 +77,19 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
api.listMembers(nextWorkspace.id),
api.listAgents({ workspace_id: nextWorkspace.id }),
api.listMembers(nextWorkspace.id).catch((e) => {
logger.error("failed to load members", e);
toast.error("Failed to load members");
return [] as MemberWithUser[];
}),
api.listAgents({ workspace_id: nextWorkspace.id }).catch((e) => {
logger.error("failed to load agents", e);
toast.error("Failed to load agents");
return [] as Agent[];
}),
api.listSkills().catch(() => [] as Skill[]),
useIssueStore.getState().fetch(),
useInboxStore.getState().fetch(),
useIssueStore.getState().fetch().catch(() => {}),
useInboxStore.getState().fetch().catch(() => {}),
]);
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
@ -113,16 +122,27 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
refreshWorkspaces: async () => {
const { workspace, hydrateWorkspace } = get();
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
try {
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
} catch (e) {
logger.error("failed to refresh workspaces", e);
toast.error("Failed to refresh workspaces");
return get().workspaces;
}
},
refreshMembers: async () => {
const { workspace } = get();
if (!workspace) return;
const members = await api.listMembers(workspace.id);
set({ members });
try {
const members = await api.listMembers(workspace.id);
set({ members });
} catch (e) {
logger.error("failed to refresh members", e);
toast.error("Failed to refresh members");
}
},
updateAgent: (id, updates) =>
@ -133,23 +153,33 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
refreshAgents: async () => {
const { workspace } = get();
if (!workspace) return;
const agents = await api.listAgents({ workspace_id: workspace.id });
set({ agents });
try {
const agents = await api.listAgents({ workspace_id: workspace.id });
set({ agents });
} catch (e) {
logger.error("failed to refresh agents", e);
toast.error("Failed to refresh agents");
}
},
refreshSkills: async () => {
const { workspace, skills: existing } = get();
if (!workspace) return;
const fetched = await api.listSkills();
// listSkills doesn't include files — preserve files from existing entries
const filesById = new Map(
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
);
const merged = fetched.map((s) => ({
...s,
files: s.files ?? filesById.get(s.id) ?? [],
}));
set({ skills: merged });
try {
const fetched = await api.listSkills();
// listSkills doesn't include files — preserve files from existing entries
const filesById = new Map(
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
);
const merged = fetched.map((s) => ({
...s,
files: s.files ?? filesById.get(s.id) ?? [],
}));
set({ skills: merged });
} catch (e) {
logger.error("failed to refresh skills", e);
toast.error("Failed to refresh skills");
}
},
upsertSkill: (skill) => {