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:
viettranxandClaude Opus 4.6 committed 2026-03-05 11:57:59 +07:00
1 parent 7d211fa796
commit 4120226f24
68 files changed
+632 -240

No files matched your search

+27 -1
View File
@@ -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 && (
+8 -1
View File
@@ -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">
+14 -5
View File
@@ -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>
);
+1 -1
View File
@@ -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 && (
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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}
+60
View File
@@ -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>
);
}
+12 -6
View File
@@ -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 };
+20 -1
View File
@@ -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
}
+5 -6
View File
@@ -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 };
}
+26 -6
View File
@@ -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 };
}
+3 -3
View File
@@ -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 && (
+1 -1
View File
@@ -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>
+69 -12
View File
@@ -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}
+1 -1
View File
@@ -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} />
+3 -6
View File
@@ -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,
};
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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>
+32 -7
View File
@@ -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>
+2 -2
View File
@@ -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 && (
+26 -7
View File
@@ -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],
);
+1 -1
View File
@@ -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>
+4 -4
View File
@@ -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>
)}
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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}
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -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">
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+43
View File
@@ -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" }),
};
+5
View File
@@ -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 }),
}));