mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
refactor(ui): mobile responsiveness, toast system, service layer, and modal fixes
- Add mobile sidebar drawer, chat sidebar drawer, and responsive layouts - Fix hooks: useApprovals stale closure, useClipboard cleanup, error states - Expand cache invalidation for cron/health/usage events, add staleTime - Create toast notification system (Zustand store + Toaster component) - Integrate toast into 7 mutation hooks (providers, agents, cron, mcp, etc.) - Enforce service layer: move all useHttp calls from pages to hooks - Replace inline SVGs with Lucide icons - Fix modal mobile support: responsive max-w, grid-cols, padding - Reduce dialog padding on mobile (p-4 sm:p-6) - Add responsive grid stacking in agent/provider/custom-tool forms - Fix trace detail span tree indent and token display on mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d211fa796
commit
4120226f24
68 files changed
+632
-240
No files matched your search
@@ -4,14 +4,40 @@ import { Sidebar } from "./sidebar";
|
||||
import { Topbar } from "./topbar";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AppLayout() {
|
||||
const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed);
|
||||
const mobileSidebarOpen = useUiStore((s) => s.mobileSidebarOpen);
|
||||
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{mobileSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 transition-opacity"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Slide-out sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-in-out",
|
||||
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<Sidebar collapsed={false} onNavItemClick={() => setMobileSidebarOpen(false)} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
)}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Topbar />
|
||||
{!connected && (
|
||||
|
||||
@@ -28,15 +28,22 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onNavItemClick?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed }: SidebarProps) {
|
||||
export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full flex-col border-r bg-sidebar text-sidebar-foreground transition-all duration-200",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Close mobile drawer when clicking a nav link
|
||||
if (onNavItemClick && (e.target as HTMLElement).closest("a")) {
|
||||
onNavItemClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Logo / title */}
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import { Moon, Sun, PanelLeftClose, PanelLeftOpen, LogOut } from "lucide-react";
|
||||
import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut } from "lucide-react";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
|
||||
export function Topbar() {
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const setTheme = useUiStore((s) => s.setTheme);
|
||||
const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed);
|
||||
const toggleSidebar = useUiStore((s) => s.toggleSidebar);
|
||||
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
|
||||
const userId = useAuthStore((s) => s.userId);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
const handleSidebarToggle = isMobile
|
||||
? () => setMobileSidebarOpen(true)
|
||||
: toggleSidebar;
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b bg-background px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onClick={handleSidebarToggle}
|
||||
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
title={isMobile ? "Open menu" : sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
{isMobile ? (
|
||||
<Menu className="h-4 w-4" />
|
||||
) : sidebarCollapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
@@ -29,7 +38,7 @@ export function Topbar() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{userId && (
|
||||
{userId && !isMobile && (
|
||||
<span className="text-xs text-muted-foreground">{userId}</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { WsProvider } from "./ws-provider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -20,6 +21,7 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<WsProvider>{children}</WsProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ interface PageHeaderProps {
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
|
||||
@@ -32,11 +32,11 @@ export function Pagination({
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||
<div className="flex flex-col items-center gap-2 border-t px-4 py-3 sm:flex-row sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{total} {total === 1 ? "item" : "items"}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Rows</span>
|
||||
<Select
|
||||
|
||||
@@ -63,7 +63,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-6 duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CheckCircle, XCircle, AlertTriangle, Info, X } from "lucide-react";
|
||||
import { useToastStore, type Toast } from "@/stores/use-toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const icons: Record<Toast["variant"], typeof Info> = {
|
||||
success: CheckCircle,
|
||||
destructive: XCircle,
|
||||
warning: AlertTriangle,
|
||||
default: Info,
|
||||
};
|
||||
|
||||
const styles: Record<Toast["variant"], string> = {
|
||||
success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
destructive: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
|
||||
warning: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
default: "border-border bg-card text-foreground",
|
||||
};
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }) {
|
||||
const dismiss = useToastStore((s) => s.dismiss);
|
||||
const Icon = icons[toast.variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-lg border p-4 shadow-lg backdrop-blur-sm animate-in slide-in-from-bottom-2 fade-in duration-200",
|
||||
styles[toast.variant],
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">{toast.title}</p>
|
||||
{toast.message && (
|
||||
<p className="text-xs opacity-80">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(toast.id)}
|
||||
className="shrink-0 rounded-sm opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col-reverse gap-2 pointer-events-none sm:bottom-6 sm:right-6">
|
||||
{toasts.slice(-3).map((t) => (
|
||||
<ToastItem key={t.id} toast={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
|
||||
export function useClipboard(timeout = 2000) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
const markCopied = useCallback(() => {
|
||||
setCopied(true);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), timeout);
|
||||
}, [timeout]);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), timeout);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement("textarea");
|
||||
@@ -19,11 +26,10 @@ export function useClipboard(timeout = 2000) {
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), timeout);
|
||||
}
|
||||
markCopied();
|
||||
},
|
||||
[timeout],
|
||||
[markCopied],
|
||||
);
|
||||
|
||||
return { copied, copy };
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AgentEventPayload } from "@/types/chat";
|
||||
export function useWsQueryInvalidation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// When an agent run completes/fails → refresh sessions + traces
|
||||
// When an agent run completes/fails → refresh sessions + traces + usage
|
||||
const handleAgentEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
const event = payload as AgentEventPayload;
|
||||
@@ -20,10 +20,29 @@ export function useWsQueryInvalidation() {
|
||||
if (event.type === "run.completed" || event.type === "run.failed") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.traces.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usage.all });
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Cron events → refresh cron jobs list
|
||||
const handleCronEvent = useCallback(
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.cron.all });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Health events → refresh agents list (agent status may have changed)
|
||||
const handleHealthEvent = useCallback(
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useWsEvent(Events.AGENT, handleAgentEvent);
|
||||
useWsEvent(Events.CRON, handleCronEvent);
|
||||
useWsEvent(Events.HEALTH, handleHealthEvent);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 overflow-y-auto min-h-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input
|
||||
@@ -137,7 +137,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
<p className="text-xs text-muted-foreground">Lowercase, numbers, hyphens</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Provider *</Label>
|
||||
{enabledProviders.length > 0 ? (
|
||||
|
||||
@@ -58,7 +58,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
|
||||
if (loading || !agent) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
@@ -71,7 +71,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
const subtitle = agentSubtitle(agent);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
|
||||
@@ -159,6 +159,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
agentId={agentId}
|
||||
agentName={title}
|
||||
onCompleted={() => {}}
|
||||
onResummon={async () => { await resummonAgent(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import type { AgentShareData } from "@/types/agent";
|
||||
import { useAgentShares } from "../hooks/use-agent-shares";
|
||||
|
||||
interface AgentSharesTabProps {
|
||||
agentId: string;
|
||||
@@ -33,41 +32,17 @@ function roleBadgeVariant(role: string) {
|
||||
}
|
||||
|
||||
export function AgentSharesTab({ agentId }: AgentSharesTabProps) {
|
||||
const http = useHttp();
|
||||
const [shares, setShares] = useState<AgentShareData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { shares, loading, addShare, revokeShare } = useAgentShares(agentId);
|
||||
const [newUserId, setNewUserId] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [revokeTarget, setRevokeTarget] = useState<string | null>(null);
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await http.get<{ shares: AgentShareData[] }>(
|
||||
`/v1/agents/${agentId}/shares`,
|
||||
);
|
||||
setShares(res.shares ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [http, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
}, [loadShares]);
|
||||
|
||||
const addShare = async () => {
|
||||
const handleAddShare = async () => {
|
||||
if (!newUserId.trim()) return;
|
||||
try {
|
||||
await http.post(`/v1/agents/${agentId}/shares`, {
|
||||
user_id: newUserId.trim(),
|
||||
role: newRole,
|
||||
});
|
||||
await addShare(newUserId.trim(), newRole);
|
||||
setNewUserId("");
|
||||
setNewRole("user");
|
||||
loadShares();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -87,7 +62,7 @@ export function AgentSharesTab({ agentId }: AgentSharesTabProps) {
|
||||
onChange={(e) => setNewUserId(e.target.value)}
|
||||
placeholder="Enter user ID..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newUserId.trim()) addShare();
|
||||
if (e.key === "Enter" && newUserId.trim()) handleAddShare();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -106,7 +81,7 @@ export function AgentSharesTab({ agentId }: AgentSharesTabProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={addShare} disabled={!newUserId.trim()} className="gap-1.5">
|
||||
<Button onClick={handleAddShare} disabled={!newUserId.trim()} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
@@ -168,8 +143,7 @@ export function AgentSharesTab({ agentId }: AgentSharesTabProps) {
|
||||
onConfirm={async () => {
|
||||
if (revokeTarget) {
|
||||
try {
|
||||
await http.delete(`/v1/agents/${agentId}/shares/${revokeTarget}`);
|
||||
loadShares();
|
||||
await revokeShare(revokeTarget);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { CardSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useAgents } from "./hooks/use-agents";
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { AgentCreateDialog } from "./agent-create-dialog";
|
||||
@@ -20,8 +19,7 @@ import { usePagination } from "@/hooks/use-pagination";
|
||||
export function AgentsPage() {
|
||||
const { id: detailId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const http = useHttp();
|
||||
const { agents, loading, createAgent, deleteAgent, refresh } = useAgents();
|
||||
const { agents, loading, createAgent, deleteAgent, refresh, resummonAgent } = useAgents();
|
||||
const showSkeleton = useDeferredLoading(loading && agents.length === 0);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -31,10 +29,10 @@ export function AgentsPage() {
|
||||
|
||||
const handleResummon = async (agent: { id: string; display_name?: string; agent_key: string }) => {
|
||||
try {
|
||||
await http.post(`/v1/agents/${agent.id}/resummon`);
|
||||
await resummonAgent(agent.id);
|
||||
setSummoningAgent({ id: agent.id, name: agent.display_name || agent.agent_key });
|
||||
} catch {
|
||||
// error handled by http hook
|
||||
// error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,7 +59,7 @@ export function AgentsPage() {
|
||||
useEffect(() => { resetPage(); }, [search, resetPage]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Agents"
|
||||
description="Manage your AI agents"
|
||||
@@ -169,6 +167,7 @@ export function AgentsPage() {
|
||||
agentId={summoningAgent.id}
|
||||
agentName={summoningAgent.name}
|
||||
onCompleted={refresh}
|
||||
onResummon={resummonAgent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import type { AgentShareData } from "@/types/agent";
|
||||
|
||||
export function useAgentShares(agentId: string) {
|
||||
const http = useHttp();
|
||||
const [shares, setShares] = useState<AgentShareData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await http.get<{ shares: AgentShareData[] }>(
|
||||
`/v1/agents/${agentId}/shares`,
|
||||
);
|
||||
setShares(res.shares ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [http, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
}, [loadShares]);
|
||||
|
||||
const addShare = useCallback(
|
||||
async (userId: string, role: string) => {
|
||||
await http.post(`/v1/agents/${agentId}/shares`, {
|
||||
user_id: userId,
|
||||
role,
|
||||
});
|
||||
loadShares();
|
||||
},
|
||||
[http, agentId, loadShares],
|
||||
);
|
||||
|
||||
const revokeShare = useCallback(
|
||||
async (userId: string) => {
|
||||
await http.delete(`/v1/agents/${agentId}/shares/${userId}`);
|
||||
loadShares();
|
||||
},
|
||||
[http, agentId, loadShares],
|
||||
);
|
||||
|
||||
return { shares, loading, refresh: loadShares, addShare, revokeShare };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useWs, useHttp } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface AgentInfoWs {
|
||||
@@ -61,20 +62,39 @@ export function useAgents() {
|
||||
|
||||
const createAgent = useCallback(
|
||||
async (data: Partial<AgentData>) => {
|
||||
const res = await http.post<AgentData>("/v1/agents", data);
|
||||
await invalidate();
|
||||
return res;
|
||||
try {
|
||||
const res = await http.post<AgentData>("/v1/agents", data);
|
||||
await invalidate();
|
||||
toast.success("Agent created", `${data.display_name || data.agent_key || "Agent"} has been added`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error("Failed to create agent", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (id: string) => {
|
||||
await http.delete(`/v1/agents/${id}`);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.delete(`/v1/agents/${id}`);
|
||||
await invalidate();
|
||||
toast.success("Agent deleted");
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete agent", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
return { agents, loading, error, refresh: invalidate, createAgent, deleteAgent };
|
||||
const resummonAgent = useCallback(
|
||||
async (id: string) => {
|
||||
await http.post(`/v1/agents/${id}/resummon`);
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
return { agents, loading, error, refresh: invalidate, createAgent, deleteAgent, resummonAgent };
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
|
||||
interface SummoningModalProps {
|
||||
@@ -16,6 +15,7 @@ interface SummoningModalProps {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
onCompleted: () => void;
|
||||
onResummon: (agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const SUMMONING_FILES = [
|
||||
@@ -29,8 +29,8 @@ export function SummoningModal({
|
||||
agentId,
|
||||
agentName,
|
||||
onCompleted,
|
||||
onResummon,
|
||||
}: SummoningModalProps) {
|
||||
const http = useHttp();
|
||||
const [generatedFiles, setGeneratedFiles] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<"summoning" | "completed" | "failed">("summoning");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
@@ -76,7 +76,7 @@ export function SummoningModal({
|
||||
const handleRetry = async () => {
|
||||
setRetrying(true);
|
||||
try {
|
||||
await http.post(`/v1/agents/${agentId}/resummon`);
|
||||
await onResummon(agentId);
|
||||
setGeneratedFiles([]);
|
||||
setStatus("summoning");
|
||||
setErrorMsg("");
|
||||
|
||||
@@ -19,7 +19,7 @@ export function ApprovalsPage() {
|
||||
const [approveTarget, setApproveTarget] = useState<{ approval: PendingApproval; always: boolean } | null>(null);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Approvals"
|
||||
description="Pending execution approvals"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Methods, Events } from "@/api/protocol";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
|
||||
export interface PendingApproval {
|
||||
id: string;
|
||||
@@ -14,15 +15,17 @@ export function useApprovals() {
|
||||
const ws = useWs();
|
||||
const [pending, setPending] = useState<PendingApproval[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!ws.isConnected) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await ws.call<{ pending: PendingApproval[] }>(Methods.APPROVALS_LIST);
|
||||
setPending(res.pending ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load approvals");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -33,30 +36,38 @@ export function useApprovals() {
|
||||
}, [load]);
|
||||
|
||||
// Listen for new approval requests
|
||||
useWsEvent(Events.EXEC_APPROVAL_REQUESTED, () => {
|
||||
load();
|
||||
});
|
||||
useWsEvent(Events.EXEC_APPROVAL_REQUESTED, load);
|
||||
|
||||
// Listen for resolved approvals
|
||||
useWsEvent(Events.EXEC_APPROVAL_RESOLVED, () => {
|
||||
load();
|
||||
});
|
||||
useWsEvent(Events.EXEC_APPROVAL_RESOLVED, load);
|
||||
|
||||
const approve = useCallback(
|
||||
async (id: string, always = false) => {
|
||||
await ws.call(Methods.APPROVALS_APPROVE, { id, always });
|
||||
setPending((prev) => prev.filter((a) => a.id !== id));
|
||||
try {
|
||||
await ws.call(Methods.APPROVALS_APPROVE, { id, always });
|
||||
setPending((prev) => prev.filter((a) => a.id !== id));
|
||||
toast.success("Approved", always ? "Command always allowed" : "Command approved");
|
||||
} catch (err) {
|
||||
toast.error("Failed to approve", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const deny = useCallback(
|
||||
async (id: string) => {
|
||||
await ws.call(Methods.APPROVALS_DENY, { id });
|
||||
setPending((prev) => prev.filter((a) => a.id !== id));
|
||||
try {
|
||||
await ws.call(Methods.APPROVALS_DENY, { id });
|
||||
setPending((prev) => prev.filter((a) => a.id !== id));
|
||||
toast.success("Denied", "Command denied");
|
||||
} catch (err) {
|
||||
toast.error("Failed to deny", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
return { pending, loading, refresh: load, approve, deny };
|
||||
return { pending, loading, error, refresh: load, approve, deny };
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function BuiltinToolsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Built-in Tools"
|
||||
description="Manage system built-in tools. Enable/disable or configure settings globally."
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useBuiltinTools() {
|
||||
const res = await http.get<{ tools: BuiltinToolData[] }>("/v1/tools/builtin");
|
||||
return res.tools ?? [];
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
|
||||
|
||||
if (loading || !instance) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
@@ -53,7 +53,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
|
||||
const isTelegram = instance.channel_type === "telegram";
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
|
||||
|
||||
@@ -219,7 +219,7 @@ export function ChannelInstanceFormDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!loading && canClose) onOpenChange(v); }}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg flex flex-col">
|
||||
<DialogContent className="max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
{hasWizard && (
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ChannelsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Channels"
|
||||
description="Manage channel instances"
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ChannelsStatusView({ channels, loading, spinning, refresh }: Cha
|
||||
const showSkeleton = useDeferredLoading(loading && entries.length === 0);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Channels"
|
||||
description="Communication channel status"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { ChannelInstanceData, ChannelInstanceInput } from "@/types/channel";
|
||||
|
||||
export type { ChannelInstanceData, ChannelInstanceInput };
|
||||
@@ -52,25 +53,43 @@ export function useChannelInstances(filters: ChannelInstanceFilters = {}) {
|
||||
|
||||
const createInstance = useCallback(
|
||||
async (data: ChannelInstanceInput) => {
|
||||
const res = await http.post<{ id: string }>("/v1/channels/instances", data);
|
||||
await invalidate();
|
||||
return res;
|
||||
try {
|
||||
const res = await http.post<{ id: string }>("/v1/channels/instances", data);
|
||||
await invalidate();
|
||||
toast.success("Channel created", `${data.name} has been added`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error("Failed to create channel", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const updateInstance = useCallback(
|
||||
async (id: string, data: Partial<ChannelInstanceInput>) => {
|
||||
await http.put(`/v1/channels/instances/${id}`, data);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.put(`/v1/channels/instances/${id}`, data);
|
||||
await invalidate();
|
||||
toast.success("Channel updated");
|
||||
} catch (err) {
|
||||
toast.error("Failed to update channel", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteInstance = useCallback(
|
||||
async (id: string) => {
|
||||
await http.delete(`/v1/channels/instances/${id}`);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.delete(`/v1/channels/instances/${id}`);
|
||||
await invalidate();
|
||||
toast.success("Channel deleted");
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete channel", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
@@ -11,17 +11,19 @@ export function useChannels() {
|
||||
const ws = useWs();
|
||||
const [channels, setChannels] = useState<Record<string, ChannelStatus>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!ws.isConnected) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await ws.call<{ channels: Record<string, ChannelStatus> }>(
|
||||
Methods.CHANNELS_STATUS,
|
||||
);
|
||||
setChannels(res.channels ?? {});
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load channels");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -31,5 +33,5 @@ export function useChannels() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return { channels, loading, refresh: load };
|
||||
return { channels, loading, error, refresh: load };
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function ZaloPersonalQRDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!loading) onOpenChange(v); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Login with QR — {instanceName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { Eye } from "lucide-react";
|
||||
import { Eye, PanelLeftOpen } from "lucide-react";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatSidebar } from "./chat-sidebar";
|
||||
import { ChatThread } from "./chat-thread";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
@@ -110,21 +112,76 @@ export function ChatPage() {
|
||||
abort(sessionKey);
|
||||
}, [abort, sessionKey]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const [chatSidebarOpen, setChatSidebarOpen] = useState(false);
|
||||
|
||||
const handleSessionSelectMobile = useCallback(
|
||||
(key: string) => {
|
||||
handleSessionSelect(key);
|
||||
setChatSidebarOpen(false);
|
||||
},
|
||||
[handleSessionSelect],
|
||||
);
|
||||
|
||||
const handleNewChatMobile = useCallback(() => {
|
||||
handleNewChat();
|
||||
setChatSidebarOpen(false);
|
||||
}, [handleNewChat]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<ChatSidebar
|
||||
agentId={agentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
sessions={sessions}
|
||||
sessionsLoading={sessionsLoading}
|
||||
activeSessionKey={sessionKey}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
<div className="relative flex h-full">
|
||||
{/* Chat Sidebar */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
{chatSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={() => setChatSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-in-out",
|
||||
chatSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<ChatSidebar
|
||||
agentId={agentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
sessions={sessions}
|
||||
sessionsLoading={sessionsLoading}
|
||||
activeSessionKey={sessionKey}
|
||||
onSessionSelect={handleSessionSelectMobile}
|
||||
onNewChat={handleNewChatMobile}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ChatSidebar
|
||||
agentId={agentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
sessions={sessions}
|
||||
sessionsLoading={sessionsLoading}
|
||||
activeSessionKey={sessionKey}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main chat area */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{isMobile && (
|
||||
<div className="flex items-center border-b px-3 py-2">
|
||||
<button
|
||||
onClick={() => setChatSidebarOpen(true)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title="Open sessions"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendError && (
|
||||
<div className="border-b bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{sendError}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ChatSidebar({
|
||||
onNewChat,
|
||||
}: ChatSidebarProps) {
|
||||
return (
|
||||
<div className="flex h-full w-72 flex-col border-r">
|
||||
<div className="flex h-full w-72 max-w-[85vw] flex-col border-r bg-background">
|
||||
{/* Agent selector */}
|
||||
<div className="border-b p-3">
|
||||
<AgentSelector value={agentId} onChange={onAgentChange} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Circle } from "lucide-react";
|
||||
import { MessageBubble } from "@/components/chat/message-bubble";
|
||||
import { StreamingText } from "@/components/chat/streaming-text";
|
||||
import { ToolCallCard } from "@/components/chat/tool-call-card";
|
||||
@@ -76,9 +77,7 @@ export function ChatThread({
|
||||
{isRunning && streamText !== null && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z" />
|
||||
</svg>
|
||||
<Circle className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="max-w-[80%] rounded-lg bg-muted px-4 py-2">
|
||||
<StreamingText text={streamText} />
|
||||
@@ -90,9 +89,7 @@ export function ChatThread({
|
||||
{isRunning && streamText === null && !thinkingText && toolStream.length === 0 && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z" />
|
||||
</svg>
|
||||
<Circle className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted px-4 py-2">
|
||||
<ThinkingIndicator />
|
||||
|
||||
@@ -13,10 +13,12 @@ export function useChatSessions(agentId: string) {
|
||||
const userId = useAuthStore((s) => s.userId);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!ws.isConnected) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await ws.call<{ sessions: SessionInfo[] }>(
|
||||
Methods.SESSIONS_LIST,
|
||||
@@ -27,8 +29,8 @@ export function useChatSessions(agentId: string) {
|
||||
new Date(b.updated).getTime() - new Date(a.updated).getTime(),
|
||||
);
|
||||
setSessions(sorted);
|
||||
} catch {
|
||||
// silently fail - will retry on next load
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load sessions");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,6 +48,7 @@ export function useChatSessions(agentId: string) {
|
||||
return {
|
||||
sessions,
|
||||
loading,
|
||||
error,
|
||||
refresh: loadSessions,
|
||||
buildNewSessionKey,
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ConfigPage() {
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader title="Config" description="Gateway configuration" />
|
||||
<div className="mt-6">
|
||||
<DetailSkeleton />
|
||||
@@ -65,7 +65,7 @@ export function ConfigPage() {
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader title="Config" description="Gateway configuration" />
|
||||
<div className="mt-6">
|
||||
<EmptyState
|
||||
@@ -86,7 +86,7 @@ export function ConfigPage() {
|
||||
const isManaged = (config.database as any)?.mode === "managed";
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Config"
|
||||
description="Gateway configuration"
|
||||
|
||||
@@ -25,6 +25,7 @@ export function useConfig() {
|
||||
hashRef.current = res.hash;
|
||||
return res;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const config = data?.config ?? null;
|
||||
|
||||
@@ -66,7 +66,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg flex flex-col">
|
||||
<DialogContent className="max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Cron Job</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function CronPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Cron"
|
||||
description="Schedule recurring agent tasks"
|
||||
@@ -84,8 +84,8 @@ export function CronPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full min-w-[700px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Enabled</th>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function CronRunLogDialog({
|
||||
}: CronRunLogDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[70vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[70vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run Log: {jobName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
|
||||
export interface CronSchedule {
|
||||
kind: "at" | "every" | "cron";
|
||||
@@ -76,31 +77,55 @@ export function useCron() {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
}) => {
|
||||
await ws.call(Methods.CRON_CREATE, params);
|
||||
await invalidate();
|
||||
try {
|
||||
await ws.call(Methods.CRON_CREATE, params);
|
||||
await invalidate();
|
||||
toast.success("Cron job created", `${params.name} has been added`);
|
||||
} catch (err) {
|
||||
toast.error("Failed to create cron job", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws, invalidate],
|
||||
);
|
||||
|
||||
const toggleJob = useCallback(
|
||||
async (jobId: string, enabled: boolean) => {
|
||||
await ws.call(Methods.CRON_TOGGLE, { jobId, enabled });
|
||||
await invalidate();
|
||||
try {
|
||||
await ws.call(Methods.CRON_TOGGLE, { jobId, enabled });
|
||||
await invalidate();
|
||||
toast.success(enabled ? "Cron job enabled" : "Cron job disabled");
|
||||
} catch (err) {
|
||||
toast.error("Failed to toggle cron job", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws, invalidate],
|
||||
);
|
||||
|
||||
const deleteJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
await ws.call(Methods.CRON_DELETE, { jobId });
|
||||
await invalidate();
|
||||
try {
|
||||
await ws.call(Methods.CRON_DELETE, { jobId });
|
||||
await invalidate();
|
||||
toast.success("Cron job deleted");
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete cron job", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws, invalidate],
|
||||
);
|
||||
|
||||
const runJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
await ws.call(Methods.CRON_RUN, { jobId, mode: "force" });
|
||||
try {
|
||||
await ws.call(Methods.CRON_RUN, { jobId, mode: "force" });
|
||||
toast.success("Cron job triggered");
|
||||
} catch (err) {
|
||||
toast.error("Failed to run cron job", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ export function CustomToolFormDialog({ open, onOpenChange, tool, onSubmit }: Cus
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !loading && onOpenChange(v)}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg flex flex-col">
|
||||
<DialogContent className="max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tool ? "Edit Tool" : "Create Custom Tool"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -132,7 +132,7 @@ export function CustomToolFormDialog({ open, onOpenChange, tool, onSubmit }: Cus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ct-wd">Working Directory</Label>
|
||||
<Input id="ct-wd" value={workingDir} onChange={(e) => setWorkingDir(e.target.value)} placeholder="/path/to/dir" />
|
||||
|
||||
@@ -67,7 +67,7 @@ export function CustomToolsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Custom Tools"
|
||||
description="Manage custom shell-based tools for agents"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { CustomToolData, CustomToolInput } from "@/types/custom-tool";
|
||||
|
||||
export type { CustomToolData, CustomToolInput };
|
||||
@@ -42,25 +43,43 @@ export function useCustomTools(filters: CustomToolFilters = {}) {
|
||||
|
||||
const createTool = useCallback(
|
||||
async (data: CustomToolInput) => {
|
||||
const res = await http.post<{ id: string }>("/v1/tools/custom", data);
|
||||
await invalidate();
|
||||
return res;
|
||||
try {
|
||||
const res = await http.post<{ id: string }>("/v1/tools/custom", data);
|
||||
await invalidate();
|
||||
toast.success("Custom tool created", `${data.name} has been added`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error("Failed to create custom tool", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const updateTool = useCallback(
|
||||
async (id: string, data: Partial<CustomToolInput>) => {
|
||||
await http.put(`/v1/tools/custom/${id}`, data);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.put(`/v1/tools/custom/${id}`, data);
|
||||
await invalidate();
|
||||
toast.success("Custom tool updated");
|
||||
} catch (err) {
|
||||
toast.error("Failed to update custom tool", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteTool = useCallback(
|
||||
async (id: string) => {
|
||||
await http.delete(`/v1/tools/custom/${id}`);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.delete(`/v1/tools/custom/${id}`);
|
||||
await invalidate();
|
||||
toast.success("Custom tool deleted");
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete custom tool", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDate, formatDuration } from "@/lib/format";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { TraceDetailDialog } from "@/pages/traces/trace-detail-dialog";
|
||||
import type { DelegationHistoryRecord } from "@/types/delegation";
|
||||
import type { TraceData, SpanData } from "@/types/trace";
|
||||
@@ -16,24 +15,13 @@ interface DelegationDetailDialogProps {
|
||||
delegationId: string;
|
||||
onClose: () => void;
|
||||
getDelegation: (id: string) => Promise<DelegationHistoryRecord | null>;
|
||||
getTrace: (traceId: string) => Promise<{ trace: TraceData; spans: SpanData[] } | null>;
|
||||
}
|
||||
|
||||
export function DelegationDetailDialog({ delegationId, onClose, getDelegation }: DelegationDetailDialogProps) {
|
||||
export function DelegationDetailDialog({ delegationId, onClose, getDelegation, getTrace }: DelegationDetailDialogProps) {
|
||||
const [record, setRecord] = useState<DelegationHistoryRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewingTraceId, setViewingTraceId] = useState<string | null>(null);
|
||||
const http = useHttp();
|
||||
|
||||
const getTrace = useCallback(
|
||||
async (traceId: string): Promise<{ trace: TraceData; spans: SpanData[] } | null> => {
|
||||
try {
|
||||
return await http.get<{ trace: TraceData; spans: SpanData[] }>(`/v1/traces/${traceId}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -118,14 +106,14 @@ export function DelegationDetailDialog({ delegationId, onClose, getDelegation }:
|
||||
{/* Task */}
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Task</p>
|
||||
<pre className="whitespace-pre-wrap text-sm">{record.task}</pre>
|
||||
<pre className="whitespace-pre-wrap break-words text-sm">{record.task}</pre>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{record.result && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Result</p>
|
||||
<pre className="max-h-[50vh] overflow-y-auto whitespace-pre-wrap text-sm">
|
||||
<pre className="max-h-[50vh] overflow-y-auto whitespace-pre-wrap break-words text-sm">
|
||||
{record.result}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { Events } from "@/api/protocol";
|
||||
import { formatDate, formatDuration } from "@/lib/format";
|
||||
import { useDelegations } from "./hooks/use-delegations";
|
||||
import { useTraces } from "@/pages/traces/hooks/use-traces";
|
||||
import { DelegationDetailDialog } from "./delegation-detail-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
@@ -27,6 +28,7 @@ import type { DelegationHistoryRecord } from "@/types/delegation";
|
||||
|
||||
export function DelegationsPage() {
|
||||
const { delegations, total, loading, load, getDelegation } = useDelegations();
|
||||
const { getTrace } = useTraces();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && delegations.length === 0);
|
||||
const [sourceFilter, setSourceFilter] = useState("");
|
||||
@@ -85,7 +87,7 @@ export function DelegationsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Delegations"
|
||||
description="Agent delegation history and results"
|
||||
@@ -203,6 +205,7 @@ export function DelegationsPage() {
|
||||
delegationId={selectedId}
|
||||
onClose={() => setSelectedId(null)}
|
||||
getDelegation={getDelegation}
|
||||
getTrace={getTrace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@ interface LoginLayoutProps {
|
||||
|
||||
export function LoginLayout({ children, subtitle }: LoginLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-lg border bg-card p-8 shadow-sm">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-lg border bg-card p-6 shadow-sm sm:p-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">GoClaw</h1>
|
||||
{subtitle && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { MCPServerData, MCPServerInput, MCPAgentGrant } from "@/types/mcp";
|
||||
|
||||
export type { MCPServerData, MCPServerInput, MCPAgentGrant };
|
||||
@@ -25,25 +26,43 @@ export function useMCP() {
|
||||
|
||||
const createServer = useCallback(
|
||||
async (data: MCPServerInput) => {
|
||||
const res = await http.post<MCPServerData>("/v1/mcp/servers", data);
|
||||
await invalidate();
|
||||
return res;
|
||||
try {
|
||||
const res = await http.post<MCPServerData>("/v1/mcp/servers", data);
|
||||
await invalidate();
|
||||
toast.success("MCP server created", `${data.name} has been added`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error("Failed to create MCP server", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const updateServer = useCallback(
|
||||
async (id: string, data: Partial<MCPServerInput>) => {
|
||||
await http.put(`/v1/mcp/servers/${id}`, data);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.put(`/v1/mcp/servers/${id}`, data);
|
||||
await invalidate();
|
||||
toast.success("MCP server updated");
|
||||
} catch (err) {
|
||||
toast.error("Failed to update MCP server", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteServer = useCallback(
|
||||
async (id: string) => {
|
||||
await http.delete(`/v1/mcp/servers/${id}`);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.delete(`/v1/mcp/servers/${id}`);
|
||||
await invalidate();
|
||||
toast.success("MCP server deleted");
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete MCP server", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit }: MCPFormD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !loading && onOpenChange(v)}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg flex flex-col">
|
||||
<DialogContent className="max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{server ? "Edit MCP Server" : "Add MCP Server"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function MCPGrantsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Agent Grants - {server.display_name || server.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -126,9 +126,9 @@ export function MCPGrantsDialog({
|
||||
<Label>Current Grants</Label>
|
||||
<div className="rounded-md border">
|
||||
{grants.map((grant) => (
|
||||
<div key={grant.id} className="flex items-center justify-between border-b px-3 py-2 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">{grant.agent_id}</Badge>
|
||||
<div key={grant.id} className="flex flex-wrap items-center gap-1 border-b px-3 py-2 last:border-0 sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
|
||||
<Badge variant="outline" className="truncate font-mono text-xs">{grant.agent_id}</Badge>
|
||||
{Array.isArray(grant.tool_allow) && grant.tool_allow.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">allow: {grant.tool_allow.join(", ")}</span>
|
||||
)}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function MCPPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="MCP Servers"
|
||||
description="Manage Model Context Protocol server connections"
|
||||
|
||||
@@ -25,7 +25,7 @@ export function NodesPage() {
|
||||
const [denyTarget, setDenyTarget] = useState<PendingPairing | null>(null);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Nodes"
|
||||
description="Manage paired devices and pending pairing requests"
|
||||
@@ -97,8 +97,8 @@ export function NodesPage() {
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
Paired Devices ({pairedDevices.length})
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Channel</th>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function OverviewPage() {
|
||||
const clientList = health?.clients ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { ProviderData, ProviderInput } from "@/types/provider";
|
||||
|
||||
export type { ProviderData, ProviderInput };
|
||||
@@ -16,6 +17,7 @@ export function useProviders() {
|
||||
const res = await http.get<{ providers: ProviderData[] }>("/v1/providers");
|
||||
return res.providers ?? [];
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(
|
||||
@@ -25,25 +27,43 @@ export function useProviders() {
|
||||
|
||||
const createProvider = useCallback(
|
||||
async (data: ProviderInput) => {
|
||||
const res = await http.post<ProviderData>("/v1/providers", data);
|
||||
await invalidate();
|
||||
return res;
|
||||
try {
|
||||
const res = await http.post<ProviderData>("/v1/providers", data);
|
||||
await invalidate();
|
||||
toast.success("Provider created", `${data.name} has been added`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error("Failed to create provider", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const updateProvider = useCallback(
|
||||
async (id: string, data: Partial<ProviderInput>) => {
|
||||
await http.put(`/v1/providers/${id}`, data);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.put(`/v1/providers/${id}`, data);
|
||||
await invalidate();
|
||||
toast.success("Provider updated");
|
||||
} catch (err) {
|
||||
toast.error("Failed to update provider", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteProvider = useCallback(
|
||||
async (id: string) => {
|
||||
await http.delete(`/v1/providers/${id}`);
|
||||
await invalidate();
|
||||
try {
|
||||
await http.delete(`/v1/providers/${id}`);
|
||||
await invalidate();
|
||||
toast.success("Provider deleted");
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete provider", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
|
||||
<DialogDescription>Configure an LLM provider connection.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 overflow-y-auto min-h-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ProvidersPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Providers"
|
||||
description="Manage LLM providers"
|
||||
@@ -107,8 +107,8 @@ export function ProvidersPage() {
|
||||
description={search ? "Try a different search term." : "Add your first LLM provider to get started."}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full min-w-[700px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Name</th>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function SessionsPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader title="Sessions" description="Browse conversation sessions" />
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -80,8 +80,8 @@ export function SessionsPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Session</th>
|
||||
|
||||
@@ -19,6 +19,7 @@ export function useSkills() {
|
||||
const res = await ws.call<{ skills: SkillInfo[] }>(Methods.SKILLS_LIST);
|
||||
return res.skills ?? [];
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(
|
||||
|
||||
@@ -16,7 +16,7 @@ interface SkillDetailDialogProps {
|
||||
export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) {
|
||||
return (
|
||||
<Dialog open onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{skill.name}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function SkillsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Skills"
|
||||
description="Manage agent skills and capabilities"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import type { DelegationHistoryRecord } from "@/types/delegation";
|
||||
|
||||
export function useTeamDelegations(teamId: string) {
|
||||
const http = useHttp();
|
||||
const [records, setRecords] = useState<DelegationHistoryRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await http.get<{ records: DelegationHistoryRecord[] }>("/v1/delegations", {
|
||||
team_id: teamId,
|
||||
limit: "50",
|
||||
});
|
||||
setRecords(res.records ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [http, teamId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return { records, loading, refresh: load };
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export function TeamCreateDialog({ open, onOpenChange, onCreate }: TeamCreateDia
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Team</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { formatDate, formatDuration } from "@/lib/format";
|
||||
import type { DelegationHistoryRecord } from "@/types/delegation";
|
||||
import { useTeamDelegations } from "./hooks/use-team-delegations";
|
||||
|
||||
interface TeamDelegationsTabProps {
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export function TeamDelegationsTab({ teamId }: TeamDelegationsTabProps) {
|
||||
const http = useHttp();
|
||||
const [records, setRecords] = useState<DelegationHistoryRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await http.get<{ records: DelegationHistoryRecord[] }>("/v1/delegations", {
|
||||
team_id: teamId,
|
||||
limit: "50",
|
||||
});
|
||||
if (!cancelled) setRecords(res.records ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [teamId, http]);
|
||||
const { records, loading } = useTeamDelegations(teamId);
|
||||
|
||||
if (loading) return <DeferredSpinner />;
|
||||
|
||||
@@ -48,8 +25,8 @@ export function TeamDelegationsTab({ teamId }: TeamDelegationsTabProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Source / Target</th>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
|
||||
if (loading || !team) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
@@ -73,7 +73,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TeamsPage() {
|
||||
useEffect(() => { resetPage(); }, [search, resetPage]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Teams"
|
||||
description="Manage your agent teams"
|
||||
|
||||
@@ -189,7 +189,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
|
||||
<div>
|
||||
<div
|
||||
className="mt-1.5 min-w-0 rounded-md border text-sm"
|
||||
style={{ marginLeft: depth * 24 }}
|
||||
style={{ marginLeft: depth * 16 }}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1 px-2 py-2">
|
||||
{/* Tree toggle */}
|
||||
@@ -222,7 +222,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
|
||||
{span.name || span.tool_name || "span"}
|
||||
</span>
|
||||
{(span.input_tokens > 0 || span.output_tokens > 0) && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
|
||||
{(span.metadata?.cache_read_tokens ?? 0) > 0 && (
|
||||
<span className="ml-1 text-green-400" title="Cached tokens read">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function TracesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Traces"
|
||||
description="LLM call traces and performance data"
|
||||
@@ -73,8 +73,8 @@ export function TracesPage() {
|
||||
description="No traces found. Traces are recorded when agents process requests."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full min-w-[700px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Name</th>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function TtsPage() {
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader title="Text-to-Speech" description="Configure TTS providers and auto-apply settings" />
|
||||
<div className="mt-4">
|
||||
<TableSkeleton rows={3} />
|
||||
@@ -72,7 +72,7 @@ export function TtsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Text-to-Speech"
|
||||
description="Configure TTS providers and auto-apply settings"
|
||||
|
||||
@@ -36,7 +36,7 @@ export function UsagePage() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Usage"
|
||||
description="Token usage and costs by agent"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
variant: "default" | "success" | "destructive" | "warning";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
dismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (toast) => {
|
||||
const id = String(++counter);
|
||||
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
|
||||
|
||||
const duration = toast.duration ?? 4000;
|
||||
setTimeout(() => {
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}, duration);
|
||||
},
|
||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
}));
|
||||
|
||||
// Convenience functions for one-line usage across the app
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ title, message, variant: "success" }),
|
||||
error: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ title, message, variant: "destructive" }),
|
||||
warning: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ title, message, variant: "warning" }),
|
||||
info: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ title, message, variant: "default" }),
|
||||
};
|
||||
@@ -6,16 +6,19 @@ export type Theme = "light" | "dark" | "system";
|
||||
interface UiState {
|
||||
theme: Theme;
|
||||
sidebarCollapsed: boolean;
|
||||
mobileSidebarOpen: boolean;
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
setMobileSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
theme: (localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as Theme) ?? "dark",
|
||||
sidebarCollapsed:
|
||||
localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED) === "true",
|
||||
mobileSidebarOpen: false,
|
||||
|
||||
setTheme: (theme) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, theme);
|
||||
@@ -33,4 +36,6 @@ export const useUiStore = create<UiState>((set) => ({
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED, String(collapsed));
|
||||
set({ sidebarCollapsed: collapsed });
|
||||
},
|
||||
|
||||
setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }),
|
||||
}));
|
||||
Reference in new issue
Block a user