Merge pull request #342 from multica-ai/NevilleQingNY/fetch-latest
feat(web): skeleton loading, error toasts, confirmation dialogs
This commit is contained in:
commit
e8c2a8eff9
18 changed files with 552 additions and 118 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue