feat: UI/UX overhaul — shared CreateAgentDialog, multi-platform Channels, skeleton loading, inline Chat panel

- Extract shared CreateAgentDialog with progressive disclosure (independent agent expands identity/persona fields)
- Redesign Channels page: self-contained, groups all channel types (Discord, Telegram, Feishu, QBot) with per-group refresh
- Add skeleton loading states for Home page agents and backups
- Replace Chat Sheet overlay with inline aside panel that pushes main content
- Add RecipeCard compact mode for Home page, with visible tag backgrounds
- Upgrade toast system to support multi-toast stacking with close buttons
- Unify SelectTrigger heights to size="sm" across all pages
- Add "Done" button to Cook completion card
- Replace ~25 silent .catch(() => {}) with console.error or user-visible feedback
- Fix create_agent to inherit default workspace for non-independent agents
- Register list_channels_minimal backend command for frontend access
- Remove unused globalLoading state and refreshDiscord from App.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zhixian
2026-02-18 02:10:45 +09:00
parent 45e8e4d99b
commit 2208f33177
18 changed files with 820 additions and 574 deletions

View File

@@ -941,13 +941,19 @@ pub fn create_agent(
} else { None };
let model_display = model_value.flatten();
// If independent, create a workspace directory for this agent
// If independent, create a dedicated workspace directory;
// otherwise inherit the default workspace so the gateway doesn't auto-create one.
let workspace = if independent.unwrap_or(false) {
let ws_dir = paths.base_dir.join("workspaces").join(&agent_id);
fs::create_dir_all(&ws_dir).map_err(|e| e.to_string())?;
let ws_path = ws_dir.to_string_lossy().to_string();
Some(ws_path)
} else { None };
} else {
cfg.pointer("/agents/defaults/workspace")
.or_else(|| cfg.pointer("/agents/default/workspace"))
.and_then(Value::as_str)
.map(|s| s.to_string())
};
// Build agent entry
let mut agent_obj = serde_json::Map::new();

View File

@@ -8,6 +8,7 @@ use crate::commands::{
preview_rollback, rollback, run_doctor_command,
resolve_api_keys, read_raw_config, resolve_full_api_key, open_url, chat_via_openclaw,
backup_before_upgrade, list_backups, restore_from_backup, delete_backup,
list_channels_minimal,
list_discord_guild_channels,
refresh_discord_guild_channels,
restart_gateway,
@@ -66,6 +67,7 @@ pub fn run() {
list_backups,
restore_from_backup,
delete_backup,
list_channels_minimal,
list_discord_guild_channels,
refresh_discord_guild_channels,
restart_gateway,

View File

@@ -6,17 +6,10 @@ import { History } from "./pages/History";
import { Settings } from "./pages/Settings";
import { Channels } from "./pages/Channels";
import { Chat } from "./components/Chat";
import { GlobalLoading } from "./components/GlobalLoading";
import { DiffViewer } from "./components/DiffViewer";
import { api } from "./lib/api";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Dialog,
DialogContent,
@@ -39,12 +32,19 @@ import type { DiscordGuildChannel } from "./lib/types";
type Route = "home" | "recipes" | "cook" | "history" | "channels" | "settings";
interface ToastItem {
id: number;
message: string;
type: "success" | "error";
}
let toastIdCounter = 0;
export function App() {
const [route, setRoute] = useState<Route>("home");
const [recipeId, setRecipeId] = useState<string | null>(null);
const [recipeSource, setRecipeSource] = useState<string | undefined>(undefined);
const [discordGuildChannels, setDiscordGuildChannels] = useState<DiscordGuildChannel[]>([]);
const [globalLoading, setGlobalLoading] = useState<string | null>(null);
const [chatOpen, setChatOpen] = useState(false);
// Config dirty state
@@ -56,19 +56,19 @@ export function App() {
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState("");
const [configVersion, setConfigVersion] = useState(0);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Establish baseline on startup
useEffect(() => {
api.saveConfigBaseline().catch(() => {});
api.saveConfigBaseline().catch((e) => console.error("Failed to save config baseline:", e));
}, []);
// Poll for dirty state
const checkDirty = useCallback(() => {
api.checkConfigDirty()
.then((state) => setDirty(state.dirty))
.catch(() => {});
.catch((e) => console.error("Failed to check config dirty state:", e));
}, []);
useEffect(() => {
@@ -84,17 +84,9 @@ export function App() {
if (!localStorage.getItem("clawpal_profiles_extracted")) {
api.extractModelProfilesFromConfig()
.then(() => localStorage.setItem("clawpal_profiles_extracted", "1"))
.catch(() => {});
.catch((e) => console.error("Failed to extract model profiles:", e));
}
api.listDiscordGuildChannels().then(setDiscordGuildChannels).catch(() => {});
}, []);
const refreshDiscord = useCallback(() => {
setGlobalLoading("Resolving Discord channel names...");
api.refreshDiscordGuildChannels()
.then(setDiscordGuildChannels)
.catch(() => {})
.finally(() => setGlobalLoading(null));
api.listDiscordGuildChannels().then(setDiscordGuildChannels).catch((e) => console.error("Failed to load Discord channels:", e));
}, []);
const bumpConfigVersion = useCallback(() => {
@@ -110,13 +102,20 @@ export function App() {
setApplyError("");
setShowApplyDialog(true);
})
.catch(() => {});
.catch((e) => console.error("Failed to load config diff:", e));
};
const showToast = (message: string, type: "success" | "error" = "success") => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const showToast = useCallback((message: string, type: "success" | "error" = "success") => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const handleApplyConfirm = () => {
setApplying(true);
@@ -145,7 +144,6 @@ export function App() {
return (
<>
{globalLoading && <GlobalLoading message={globalLoading} />}
<div className="flex h-screen">
<aside className="w-[200px] min-w-[200px] bg-muted border-r border-border flex flex-col py-4">
<h1 className="px-4 text-lg font-bold mb-4">ClawPal</h1>
@@ -203,18 +201,6 @@ export function App() {
</Button>
</nav>
{/* Chat toggle */}
<div className="px-2 pb-2">
<Button
variant="outline"
className="w-full"
size="sm"
onClick={() => setChatOpen(true)}
>
Chat
</Button>
</div>
{/* Dirty config action bar */}
{dirty && (
<div className="px-2 pb-2 space-y-1.5">
@@ -238,7 +224,19 @@ export function App() {
</div>
)}
</aside>
<main className="flex-1 overflow-y-auto p-4">
<main className="flex-1 overflow-y-auto p-4 relative">
{/* Chat toggle -- top-right corner */}
{!chatOpen && (
<Button
variant="outline"
size="sm"
className="absolute top-4 right-4 z-10"
onClick={() => setChatOpen(true)}
>
Chat
</Button>
)}
{route === "home" && (
<Home
key={configVersion}
@@ -247,6 +245,7 @@ export function App() {
setRecipeSource(source);
setRoute("cook");
}}
showToast={showToast}
/>
)}
{route === "recipes" && (
@@ -272,8 +271,7 @@ export function App() {
{route === "channels" && (
<Channels
key={configVersion}
discordGuildChannels={discordGuildChannels}
onRefresh={refreshDiscord}
showToast={showToast}
/>
)}
{route === "history" && <History key={configVersion} />}
@@ -281,19 +279,27 @@ export function App() {
<Settings key={configVersion} onDataChange={bumpConfigVersion} />
)}
</main>
</div>
{/* Chat Drawer */}
<Sheet open={chatOpen} onOpenChange={setChatOpen}>
<SheetContent side="right" className="w-[380px] sm:w-[420px] p-0 flex flex-col">
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle>Chat</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-hidden px-4 pb-4">
<Chat />
</div>
</SheetContent>
</Sheet>
{/* Chat Panel -- inline, pushes main content */}
{chatOpen && (
<aside className="w-[360px] min-w-[360px] border-l border-border flex flex-col bg-background">
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h2 className="text-lg font-semibold">Chat</h2>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setChatOpen(false)}
>
&times;
</Button>
</div>
<div className="flex-1 overflow-hidden px-4 pb-4">
<Chat />
</div>
</aside>
)}
</div>
{/* Apply Changes Dialog */}
<Dialog open={showApplyDialog} onOpenChange={setShowApplyDialog}>
@@ -340,13 +346,26 @@ export function App() {
</AlertDialogContent>
</AlertDialog>
{/* Toast */}
{toast && (
<div className={cn(
"fixed bottom-4 right-4 px-4 py-2.5 rounded-md shadow-lg text-sm font-medium z-50 animate-in fade-in slide-in-from-bottom-2",
toast.type === "success" ? "bg-green-600 text-white" : "bg-destructive text-destructive-foreground"
)}>
{toast.message}
{/* Toast Stack */}
{toasts.length > 0 && (
<div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"flex items-center gap-2 px-4 py-2.5 rounded-md shadow-lg text-sm font-medium animate-in fade-in slide-in-from-bottom-2",
toast.type === "success" ? "bg-green-600 text-white" : "bg-destructive text-destructive-foreground"
)}
>
<span className="flex-1">{toast.message}</span>
<button
className="opacity-70 hover:opacity-100 text-current ml-2"
onClick={() => dismissToast(toast.id)}
>
&times;
</button>
</div>
))}
</div>
)}
</>

View File

@@ -48,7 +48,7 @@ export function Chat() {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
api.listAgentIds().then(setAgents).catch(() => {});
api.listAgentIds().then(setAgents).catch((e) => console.error("Failed to load agent IDs:", e));
}, []);
useEffect(() => {
@@ -90,7 +90,7 @@ export function Chat() {
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-2">
<Select value={agentId} onValueChange={(a) => { setAgentId(a); setSessionId(loadSessionId(a)); setMessages([]); }}>
<SelectTrigger className="w-auto h-7 text-xs">
<SelectTrigger size="sm" className="w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -0,0 +1,187 @@
import { useState } from "react";
import { api } from "../lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import type { ModelProfile } from "../lib/types";
export interface CreateAgentResult {
agentId: string;
persona?: string;
}
export function CreateAgentDialog({
open,
onOpenChange,
modelProfiles,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
modelProfiles: ModelProfile[];
onCreated: (result: CreateAgentResult) => void;
}) {
const [agentId, setAgentId] = useState("");
const [model, setModel] = useState("");
const [independent, setIndependent] = useState(false);
const [displayName, setDisplayName] = useState("");
const [emoji, setEmoji] = useState("");
const [persona, setPersona] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const reset = () => {
setAgentId("");
setModel("");
setIndependent(false);
setDisplayName("");
setEmoji("");
setPersona("");
setError("");
};
const handleCreate = async () => {
const id = agentId.trim();
if (!id) {
setError("Agent ID is required");
return;
}
setCreating(true);
setError("");
try {
const created = await api.createAgent(id, model || undefined, independent || undefined);
// Set identity if name or emoji provided
const name = displayName.trim();
const emojiVal = emoji.trim();
if (independent && (name || emojiVal)) {
await api.setupAgentIdentity(id, name || id, emojiVal || undefined).catch(() => {});
}
onOpenChange(false);
const result: CreateAgentResult = { agentId: created.id };
if (persona.trim()) result.persona = persona.trim();
reset();
onCreated(result);
} catch (e) {
setError(String(e));
} finally {
setCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Agent ID</Label>
<Input
placeholder="e.g. my-agent"
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Letters, numbers, hyphens, and underscores only.
</p>
</div>
<div className="space-y-1.5">
<Label>Model</Label>
<Select
value={model || "__default__"}
onValueChange={(val) => setModel(val === "__default__" ? "" : val)}
>
<SelectTrigger size="sm" className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
<span className="text-muted-foreground">use global default</span>
</SelectItem>
{modelProfiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.provider}/{p.model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="create-agent-independent"
checked={independent}
onCheckedChange={(checked) => {
const val = checked === true;
setIndependent(val);
if (!val) {
setDisplayName("");
setEmoji("");
setPersona("");
}
}}
/>
<Label htmlFor="create-agent-independent">Independent agent (separate workspace)</Label>
</div>
{independent && (
<>
<div className="space-y-1.5">
<Label>Display Name</Label>
<Input
placeholder="e.g. MyBot"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Emoji</Label>
<Input
placeholder="e.g. \uD83E\uDD16"
value={emoji}
onChange={(e) => setEmoji(e.target.value)}
className="w-20"
/>
</div>
<div className="space-y-1.5">
<Label>Persona</Label>
<Textarea
placeholder="You are..."
value={persona}
onChange={(e) => setPersona(e.target.value)}
rows={3}
/>
</div>
</>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -64,7 +64,7 @@ export function ParamForm({
const needsProfiles = recipe.params.some((p) => p.type === "model_profile");
useEffect(() => {
if (needsProfiles) {
api.listModelProfiles().then(setModelProfiles).catch(() => {});
api.listModelProfiles().then(setModelProfiles).catch((e) => console.error("Failed to load model profiles:", e));
}
}, [needsProfiles]);
@@ -72,7 +72,7 @@ export function ParamForm({
const needsAgents = recipe.params.some((p) => p.type === "agent");
useEffect(() => {
if (needsAgents) {
api.listAgentsOverview().then(setAgents).catch(() => {});
api.listAgentsOverview().then(setAgents).catch((e) => console.error("Failed to load agents:", e));
}
}, [needsAgents]);
@@ -140,7 +140,7 @@ export function ParamForm({
}
}}
>
<SelectTrigger id={param.id} className="w-full">
<SelectTrigger id={param.id} size="sm" className="w-full">
<SelectValue placeholder="Select a guild" />
</SelectTrigger>
<SelectContent>
@@ -165,7 +165,7 @@ export function ParamForm({
}}
disabled={!guildSelected}
>
<SelectTrigger id={param.id} className="w-full">
<SelectTrigger id={param.id} size="sm" className="w-full">
<SelectValue
placeholder={guildSelected ? "Select a channel" : "Select a guild first"}
/>
@@ -190,7 +190,7 @@ export function ParamForm({
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
>
<SelectTrigger id={param.id} className="w-full">
<SelectTrigger id={param.id} size="sm" className="w-full">
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
@@ -215,7 +215,7 @@ export function ParamForm({
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
>
<SelectTrigger id={param.id} className="w-full">
<SelectTrigger id={param.id} size="sm" className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>

View File

@@ -3,7 +3,34 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export function RecipeCard({ recipe, onCook }: { recipe: Recipe; onCook: (id: string) => void }) {
export function RecipeCard({
recipe,
onCook,
compact,
}: {
recipe: Recipe;
onCook: (id: string) => void;
compact?: boolean;
}) {
if (compact) {
return (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => onCook(recipe.id)}
>
<CardContent>
<strong>{recipe.name}</strong>
<div className="text-sm text-muted-foreground mt-1.5">
{recipe.description}
</div>
<div className="text-xs text-muted-foreground mt-2">
{recipe.steps.length} step{recipe.steps.length !== 1 ? "s" : ""} &middot; {recipe.difficulty}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
@@ -13,7 +40,7 @@ export function RecipeCard({ recipe, onCook }: { recipe: Recipe; onCook: (id: st
<CardContent>
<div className="flex flex-wrap gap-1.5 mb-2">
{recipe.tags.map((t) => (
<Badge key={t} variant="secondary">
<Badge key={t} variant="secondary" className="bg-muted-foreground/15">
{t}
</Badge>
))}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { AgentOverview, ApplyResult, BackupInfo, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
import type { AgentOverview, ApplyResult, BackupInfo, ChannelNode, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
export const api = {
getSystemStatus: (): Promise<SystemStatus> =>
@@ -78,6 +78,8 @@ export const api = {
invoke("restore_from_backup", { backupName }),
deleteBackup: (backupName: string): Promise<boolean> =>
invoke("delete_backup", { backupName }),
listChannelsMinimal: (): Promise<ChannelNode[]> =>
invoke("list_channels_minimal", {}),
listDiscordGuildChannels: (): Promise<DiscordGuildChannel[]> =>
invoke("list_discord_guild_channels", {}),
refreshDiscordGuildChannels: (): Promise<DiscordGuildChannel[]> =>

View File

@@ -1,5 +1,16 @@
export type Severity = "low" | "medium" | "high";
export interface ChannelNode {
path: string;
channelType: string | null;
mode: string | null;
allowlist: string[];
model: string | null;
hasModelField: boolean;
displayName: string | null;
nameStatus: string | null;
}
export interface DiscordGuildChannel {
guildId: string;
guildName: string;

View File

@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Parse various timestamp formats to local "YYYY-MM-DD HH:MM:SS".
* Handles History dash-separated format ("2026-02-17T14-30-00")
* and standard RFC3339 ("2026-02-17T14:30:00+00:00"). */
export function formatTime(raw: string): string {
const pad = (n: number) => String(n).padStart(2, "0");
// Try History dash format: T followed by HH-MM-SS
const dashMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
if (dashMatch) {
const [, y, mo, d, h, mi, s] = dashMatch;
const utc = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
if (!isNaN(utc.getTime())) {
return `${utc.getFullYear()}-${pad(utc.getMonth() + 1)}-${pad(utc.getDate())} ${pad(utc.getHours())}:${pad(utc.getMinutes())}:${pad(utc.getSeconds())}`;
}
}
// Fallback: RFC3339 / ISO 8601
const date = new Date(raw);
if (!isNaN(date.getTime())) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
return raw;
}
export function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let index = 0;
let value = bytes;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(1)} ${units[index]}`;
}

View File

@@ -1,12 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import type { AgentOverview, DiscordGuildChannel, ModelProfile } from "../lib/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { AgentOverview, ChannelNode, DiscordGuildChannel, ModelProfile } from "../lib/types";
import { api } from "../lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@@ -17,13 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { CreateAgentDialog, type CreateAgentResult } from "@/components/CreateAgentDialog";
interface Binding {
agentId: string;
@@ -36,124 +27,158 @@ interface AgentGroup {
agents: AgentOverview[];
}
const PLATFORM_LABELS: Record<string, string> = {
discord: "Discord",
telegram: "Telegram",
feishu: "Feishu",
qbot: "QBot",
};
function groupAgents(agents: AgentOverview[]): AgentGroup[] {
const map = new Map<string, AgentGroup>();
for (const a of agents) {
const key = a.workspace || a.id;
if (!map.has(key)) {
map.set(key, {
identity: a.name || a.id,
emoji: a.emoji,
agents: [],
});
map.set(key, { identity: a.name || a.id, emoji: a.emoji, agents: [] });
}
map.get(key)!.agents.push(a);
}
return Array.from(map.values());
}
function extractPlatform(path: string): string | null {
const parts = path.split(".");
if (parts.length >= 2 && parts[0] === "channels") return parts[1];
return null;
}
function extractPeerId(path: string): string {
return path.split(".").pop() || path;
}
export function Channels({
discordGuildChannels,
onRefresh,
showToast,
}: {
discordGuildChannels: DiscordGuildChannel[];
onRefresh: () => void;
showToast?: (message: string, type?: "success" | "error") => void;
}) {
const [agents, setAgents] = useState<AgentOverview[]>([]);
const [bindings, setBindings] = useState<Binding[]>([]);
const [saving, setSaving] = useState<string | null>(null);
const [modelProfiles, setModelProfiles] = useState<ModelProfile[]>([]);
const [channelNodes, setChannelNodes] = useState<ChannelNode[]>([]);
const [discordChannels, setDiscordChannels] = useState<DiscordGuildChannel[]>([]);
const [refreshing, setRefreshing] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
// Create agent dialog
const [showCreateAgent, setShowCreateAgent] = useState(false);
const [pendingChannelId, setPendingChannelId] = useState<string | null>(null);
const [newAgentId, setNewAgentId] = useState("");
const [newAgentModel, setNewAgentModel] = useState("");
const [newAgentIndependent, setNewAgentIndependent] = useState(false);
const [creatingAgent, setCreatingAgent] = useState(false);
const [createAgentError, setCreateAgentError] = useState("");
const [pendingChannel, setPendingChannel] = useState<{
platform: string;
peerId: string;
guildId?: string;
} | null>(null);
const refreshAgents = () => {
api.listAgentsOverview().then(setAgents).catch(() => {});
};
const refreshAgents = useCallback(() => {
api.listAgentsOverview().then(setAgents).catch((e) => console.error("Failed to load agents:", e));
}, []);
const refreshBindings = useCallback(() => {
api.listBindings().then((b) => setBindings(b as unknown as Binding[])).catch((e) => console.error("Failed to load bindings:", e));
}, []);
const refreshChannelNodes = useCallback(() => {
api.listChannelsMinimal().then(setChannelNodes).catch((e) => console.error("Failed to load channel nodes:", e));
}, []);
const refreshDiscordCache = useCallback(() => {
api.listDiscordGuildChannels().then(setDiscordChannels).catch((e) => console.error("Failed to load Discord channels:", e));
}, []);
useEffect(() => {
refreshAgents();
api.listBindings().then((b) => setBindings(b as unknown as Binding[])).catch(() => {});
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch(() => {});
}, []);
refreshBindings();
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load model profiles:", e));
refreshChannelNodes();
refreshDiscordCache();
}, [refreshAgents, refreshBindings, refreshChannelNodes, refreshDiscordCache]);
// Map channelId → agentId from bindings
const handleRefreshDiscord = () => {
setRefreshing("discord");
api.refreshDiscordGuildChannels()
.then((channels) => {
setDiscordChannels(channels);
showToast?.("Discord channels refreshed", "success");
})
.catch((e) => showToast?.(String(e), "error"))
.finally(() => setRefreshing(null));
};
const handleRefreshPlatform = (platform: string) => {
setRefreshing(platform);
api.listChannelsMinimal()
.then((nodes) => {
setChannelNodes(nodes);
showToast?.(`${PLATFORM_LABELS[platform] || platform} channels refreshed`, "success");
})
.catch((e) => showToast?.(String(e), "error"))
.finally(() => setRefreshing(null));
};
// Binding lookup: "platform:peerId" -> agentId
const channelAgentMap = useMemo(() => {
const map = new Map<string, string>();
for (const b of bindings) {
if (b.match?.channel === "discord" && b.match?.peer?.id) {
map.set(b.match.peer.id, b.agentId);
if (b.match?.channel && b.match?.peer?.id) {
map.set(`${b.match.channel}:${b.match.peer.id}`, b.agentId);
}
}
return map;
}, [bindings]);
const grouped = useMemo(() => {
// Discord channels grouped by guild
const discordGuilds = useMemo(() => {
const map = new Map<string, { guildName: string; channels: DiscordGuildChannel[] }>();
for (const gc of discordGuildChannels) {
if (!map.has(gc.guildId)) {
map.set(gc.guildId, { guildName: gc.guildName, channels: [] });
for (const ch of discordChannels) {
if (!map.has(ch.guildId)) {
map.set(ch.guildId, { guildName: ch.guildName, channels: [] });
}
map.get(gc.guildId)!.channels.push(gc);
map.get(ch.guildId)!.channels.push(ch);
}
return Array.from(map.entries());
}, [discordGuildChannels]);
}, [discordChannels]);
// Non-Discord channel nodes grouped by platform, filtered to leaf-level
const otherPlatforms = useMemo(() => {
const map = new Map<string, ChannelNode[]>();
for (const node of channelNodes) {
const platform = extractPlatform(node.path);
if (!platform || platform === "discord") continue;
if (node.channelType === "platform") continue;
if (!map.has(platform)) map.set(platform, []);
map.get(platform)!.push(node);
}
return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [channelNodes]);
const agentGroups = useMemo(() => groupAgents(agents), [agents]);
const handleAssign = async (channelId: string, agentId: string) => {
const handleAssign = async (platform: string, peerId: string, agentId: string) => {
if (agentId === "__new__") {
setPendingChannelId(channelId);
setPendingChannel({ platform, peerId });
setShowCreateAgent(true);
return;
}
setSaving(channelId);
const key = `${platform}:${peerId}`;
setSaving(key);
try {
await api.assignChannelAgent(
"discord",
channelId,
agentId === "__default__" ? null : agentId,
);
const updated = await api.listBindings();
setBindings(updated as unknown as Binding[]);
} catch {
// silently fail
await api.assignChannelAgent(platform, peerId, agentId === "__default__" ? null : agentId);
refreshBindings();
} catch (e) {
showToast?.(String(e), "error");
} finally {
setSaving(null);
}
};
const handleCreateAgent = () => {
const id = newAgentId.trim();
if (!id) {
setCreateAgentError("Agent ID is required");
return;
}
setCreatingAgent(true);
setCreateAgentError("");
api.createAgent(id, newAgentModel || undefined, newAgentIndependent || undefined)
.then((created) => {
setShowCreateAgent(false);
setNewAgentId("");
setNewAgentModel("");
setNewAgentIndependent(false);
refreshAgents();
if (pendingChannelId) {
handleAssign(pendingChannelId, created.id);
setPendingChannelId(null);
}
})
.catch((e) => setCreateAgentError(String(e)))
.finally(() => setCreatingAgent(false));
};
// Build the agent label shown in the trigger when selected
function agentDisplayLabel(agentId: string): string {
const a = agents.find((ag) => ag.id === agentId);
if (!a) return agentId;
@@ -163,149 +188,173 @@ export function Channels({
return `${emoji}${name}: ${a.id}${model}`;
}
const renderAgentSelect = (platform: string, peerId: string) => {
const key = `${platform}:${peerId}`;
const currentAgent = channelAgentMap.get(key);
return (
<Select
value={currentAgent || "__default__"}
onValueChange={(val) => handleAssign(platform, peerId, val)}
disabled={saving === key}
>
<SelectTrigger size="sm" className="text-xs">
<SelectValue>
{currentAgent ? agentDisplayLabel(currentAgent) : "main (default)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
<span className="text-muted-foreground">main (default)</span>
</SelectItem>
{agentGroups.map((group, gi) => (
<SelectGroup key={group.agents[0].workspace || group.agents[0].id}>
{gi > 0 && <SelectSeparator />}
<SelectLabel>
{group.emoji ? `${group.emoji} ` : ""}{group.identity}
</SelectLabel>
{group.agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
<code className="text-xs">{a.id}</code>
<span className="text-muted-foreground ml-1.5 text-xs">
{a.model || "default model"}
</span>
</SelectItem>
))}
</SelectGroup>
))}
<SelectSeparator />
<SelectItem value="__new__">
<span className="text-primary">+ New agent...</span>
</SelectItem>
</SelectContent>
</Select>
);
};
const hasDiscord = discordChannels.length > 0;
const hasOther = otherPlatforms.length > 0;
return (
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Channels</h2>
<Button onClick={onRefresh}>
Refresh Discord channels
</Button>
</div>
<h2 className="text-2xl font-bold mb-4">Channels</h2>
{grouped.length === 0 ? (
{!hasDiscord && !hasOther && (
<p className="text-muted-foreground">
No Discord channels cached. Click "Refresh Discord channels" to load.
No channels configured. Add channel plugins in your OpenClaw config, then refresh to see them here.
</p>
) : (
<div className="space-y-4">
{grouped.map(([guildId, { guildName, channels }]) => (
<Card key={guildId}>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<strong className="text-lg">{guildName}</strong>
<Badge variant="secondary">{guildId}</Badge>
<Badge variant="outline" className="ml-auto">Discord</Badge>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
{channels.map((ch) => {
const currentAgent = channelAgentMap.get(ch.channelId);
return (
<div
key={ch.channelId}
className="rounded-md border px-3 py-2"
>
<div className="text-sm font-medium">{ch.channelName}</div>
<div className="text-xs text-muted-foreground mt-0.5 mb-1.5">{ch.channelId}</div>
<Select
value={currentAgent || "__default__"}
onValueChange={(val) => handleAssign(ch.channelId, val)}
disabled={saving === ch.channelId}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue>
{currentAgent ? agentDisplayLabel(currentAgent) : "main (default)"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
<span className="text-muted-foreground">main (default)</span>
</SelectItem>
{agentGroups.map((group, gi) => (
<SelectGroup key={group.agents[0].workspace || group.agents[0].id}>
{gi > 0 && <SelectSeparator />}
<SelectLabel>
{group.emoji ? `${group.emoji} ` : ""}{group.identity}
</SelectLabel>
{group.agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
<code className="text-xs">{a.id}</code>
<span className="text-muted-foreground ml-1.5 text-xs">
{a.model || "default model"}
</span>
</SelectItem>
))}
</SelectGroup>
))}
<SelectSeparator />
<SelectItem value="__new__">
<span className="text-primary">+ New agent...</span>
</SelectItem>
</SelectContent>
</Select>
</div>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Create Agent Dialog */}
<Dialog open={showCreateAgent} onOpenChange={(open) => {
setShowCreateAgent(open);
if (!open) setPendingChannelId(null);
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Agent ID</Label>
<Input
placeholder="e.g. my-agent"
value={newAgentId}
onChange={(e) => setNewAgentId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Letters, numbers, hyphens, and underscores only.
</p>
</div>
<div className="space-y-1.5">
<Label>Model</Label>
<Select
value={newAgentModel || "__default__"}
onValueChange={(val) => setNewAgentModel(val === "__default__" ? "" : val)}
<div className="space-y-6">
{/* Discord section */}
<Card>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<strong className="text-lg">Discord</strong>
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={handleRefreshDiscord}
disabled={refreshing === "discord"}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
<span className="text-muted-foreground">use global default</span>
</SelectItem>
{modelProfiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.provider}/{p.model}
</SelectItem>
))}
</SelectContent>
</Select>
{refreshing === "discord" ? "Refreshing..." : "Refresh"}
</Button>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="ch-independent-agent"
checked={newAgentIndependent}
onCheckedChange={(checked) => setNewAgentIndependent(checked === true)}
/>
<Label htmlFor="ch-independent-agent">Independent agent (separate workspace)</Label>
</div>
{createAgentError && (
<p className="text-sm text-destructive">{createAgentError}</p>
{discordGuilds.length === 0 ? (
<p className="text-sm text-muted-foreground">
No Discord channels cached. Click "Refresh" to discover channels from Discord.
</p>
) : (
<div className="space-y-4">
{discordGuilds.map(([guildId, { guildName, channels }]) => (
<div key={guildId}>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm font-medium">{guildName}</span>
<Badge variant="secondary" className="text-[10px]">{guildId}</Badge>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
{channels.map((ch) => (
<div key={ch.channelId} className="rounded-md border px-3 py-2">
<div className="text-sm font-medium">{ch.channelName}</div>
<div className="text-xs text-muted-foreground mt-0.5 mb-1.5">{ch.channelId}</div>
{renderAgentSelect("discord", ch.channelId)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowCreateAgent(false); setPendingChannelId(null); }}>
Cancel
</Button>
<Button onClick={handleCreateAgent} disabled={creatingAgent}>
{creatingAgent ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
{/* Other platform sections */}
{otherPlatforms.map(([platform, nodes]) => (
<Card key={platform}>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<strong className="text-lg">{PLATFORM_LABELS[platform] || platform}</strong>
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => handleRefreshPlatform(platform)}
disabled={refreshing === platform}
>
{refreshing === platform ? "Refreshing..." : "Refresh"}
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-2">
{nodes.map((node) => {
const peerId = extractPeerId(node.path);
return (
<div key={node.path} className="rounded-md border px-3 py-2">
<div className="text-sm font-medium">
{node.displayName || peerId}
</div>
<div
className="text-xs text-muted-foreground mt-0.5 mb-1.5 truncate"
title={node.path}
>
{node.path.length > 40 ? `...${node.path.slice(-37)}` : node.path}
</div>
{renderAgentSelect(platform, peerId)}
</div>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
{/* Create Agent Dialog */}
<CreateAgentDialog
open={showCreateAgent}
onOpenChange={(open) => {
setShowCreateAgent(open);
if (!open) setPendingChannel(null);
}}
modelProfiles={modelProfiles}
onCreated={(result: CreateAgentResult) => {
refreshAgents();
if (pendingChannel) {
handleAssign(pendingChannel.platform, pendingChannel.peerId, result.agentId);
// Apply persona as systemPrompt on the channel if provided
if (result.persona && pendingChannel.platform === "discord") {
const ch = discordChannels.find((c) => c.channelId === pendingChannel.peerId);
if (ch) {
const patch = JSON.stringify({
channels: { discord: { guilds: { [ch.guildId]: { channels: { [ch.channelId]: { systemPrompt: result.persona } } } } } },
});
api.applyConfigPatch(patch, {}).catch((e) => showToast?.(String(e), "error"));
}
}
setPendingChannel(null);
}
}}
/>
</section>
);
}

View File

@@ -208,6 +208,9 @@ export function Cook({
Use "Apply Changes" in the sidebar to restart the gateway and activate config changes.
</p>
)}
<Button className="mt-4" onClick={onDone}>
Done
</Button>
</CardContent>
</Card>
)}

View File

@@ -96,7 +96,7 @@ export function Doctor() {
}, []);
useEffect(() => {
api.listBackups().then(setBackups).catch(() => {});
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
}, []);
return (

View File

@@ -11,18 +11,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { HistoryItem, PreviewResult } from "../lib/types";
/** Parse "2026-02-17T14-30-00-recipe" style timestamps to local YYYY-MM-DD HH:MM:SS */
function formatHistoryTime(raw: string): string {
// createdAt is like "2026-02-17T14-30-00" (UTC)
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
if (!match) return raw;
const [, y, mo, d, h, mi, s] = match;
const utc = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
if (isNaN(utc.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${utc.getFullYear()}-${pad(utc.getMonth() + 1)}-${pad(utc.getDate())} ${pad(utc.getHours())}:${pad(utc.getMinutes())}:${pad(utc.getSeconds())}`;
}
import { formatTime } from "@/lib/utils";
export function History() {
const [history, setHistory] = useState<HistoryItem[]>([]);
@@ -54,13 +43,13 @@ export function History() {
<Card key={item.id} className={isRollback ? "border-dashed opacity-75" : ""}>
<CardContent>
<div className="flex items-center gap-2 text-sm flex-wrap">
<span className="text-muted-foreground">{formatHistoryTime(item.createdAt)}</span>
<span className="text-muted-foreground">{formatTime(item.createdAt)}</span>
{isRollback ? (
<>
<Badge variant="outline">rollback</Badge>
<span className="text-muted-foreground">
Reverted {rollbackTarget
? `"${rollbackTarget.recipeId || "manual"}" from ${formatHistoryTime(rollbackTarget.createdAt)}`
? `"${rollbackTarget.recipeId || "manual"}" from ${formatTime(rollbackTarget.createdAt)}`
: item.recipeId || "unknown"
}
</span>

View File

@@ -3,9 +3,6 @@ import { api } from "../lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@@ -13,13 +10,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -31,7 +21,11 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { StatusLight, AgentOverview, Recipe, HistoryItem, ModelProfile } from "../lib/types";
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
import { RecipeCard } from "@/components/RecipeCard";
import { Skeleton } from "@/components/ui/skeleton";
import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile } from "../lib/types";
import { formatTime, formatBytes } from "@/lib/utils";
interface AgentGroup {
identity: string;
@@ -56,13 +50,19 @@ function groupAgents(agents: AgentOverview[]): AgentGroup[] {
return Array.from(map.values());
}
export function Home({ onCook }: { onCook?: (recipeId: string, source?: string) => void }) {
export function Home({
onCook,
showToast,
}: {
onCook?: (recipeId: string, source?: string) => void;
showToast?: (message: string, type?: "success" | "error") => void;
}) {
const [status, setStatus] = useState<StatusLight | null>(null);
const [version, setVersion] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<{ available: boolean; latest?: string } | null>(null);
const [agents, setAgents] = useState<AgentOverview[]>([]);
const [agents, setAgents] = useState<AgentOverview[] | null>(null);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
const [modelProfiles, setModelProfiles] = useState<ModelProfile[]>([]);
const [savingModel, setSavingModel] = useState(false);
const [backingUp, setBackingUp] = useState(false);
@@ -70,11 +70,6 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
// Create agent dialog
const [showCreateAgent, setShowCreateAgent] = useState(false);
const [newAgentId, setNewAgentId] = useState("");
const [newAgentModel, setNewAgentModel] = useState("");
const [newAgentIndependent, setNewAgentIndependent] = useState(false);
const [creatingAgent, setCreatingAgent] = useState(false);
const [createAgentError, setCreateAgentError] = useState("");
// Health status with grace period: retry quickly when unhealthy, then slow-poll
const [statusSettled, setStatusSettled] = useState(false);
@@ -91,7 +86,7 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
} else {
setStatusSettled(true);
}
}).catch(() => {});
}).catch((e) => console.error("Failed to fetch status:", e));
}, []);
useEffect(() => {
@@ -101,21 +96,28 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
return () => clearInterval(interval);
}, [fetchStatus, statusSettled]);
const refreshAgents = () => {
api.listAgentsOverview().then(setAgents).catch(() => {});
const refreshAgents = useCallback(() => {
api.listAgentsOverview().then(setAgents).catch((e) => console.error("Failed to load agents:", e));
}, []);
useEffect(() => {
refreshAgents();
// Auto-refresh agents every 15s
const interval = setInterval(refreshAgents, 15000);
return () => clearInterval(interval);
}, [refreshAgents]);
useEffect(() => {
api.listRecipes().then((r) => setRecipes(r.slice(0, 4))).catch((e) => console.error("Failed to load recipes:", e));
}, []);
const refreshBackups = () => {
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
};
useEffect(refreshAgents, []);
useEffect(refreshBackups, []);
useEffect(() => {
api.listRecipes().then((r) => setRecipes(r.slice(0, 4))).catch(() => {});
}, []);
useEffect(() => {
api.listHistory(5, 0).then((h) => setHistory(h.items)).catch(() => {});
}, []);
useEffect(() => {
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch(() => {});
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load model profiles:", e));
}, []);
// Match current global model value to a profile ID
@@ -132,7 +134,7 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
return null;
}, [status?.globalDefaultModel, modelProfiles]);
const agentGroups = useMemo(() => groupAgents(agents), [agents]);
const agentGroups = useMemo(() => groupAgents(agents || []), [agents]);
// Heavy call: version + update check, deferred
useEffect(() => {
@@ -145,35 +147,15 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
latest: s.openclawUpdate.latestVersion,
});
}
}).catch(() => {});
}).catch((e) => console.error("Failed to fetch system status:", e));
}, 100);
return () => clearTimeout(timer);
}, []);
const handleCreateAgent = () => {
const id = newAgentId.trim();
if (!id) {
setCreateAgentError("Agent ID is required");
return;
}
setCreatingAgent(true);
setCreateAgentError("");
api.createAgent(id, newAgentModel || undefined, newAgentIndependent || undefined)
.then(() => {
setShowCreateAgent(false);
setNewAgentId("");
setNewAgentModel("");
setNewAgentIndependent(false);
refreshAgents();
})
.catch((e) => setCreateAgentError(String(e)))
.finally(() => setCreatingAgent(false));
};
const handleDeleteAgent = (agentId: string) => {
api.deleteAgent(agentId)
.then(() => refreshAgents())
.catch(() => {});
.catch((e) => showToast?.(String(e), "error"));
};
return (
@@ -223,7 +205,7 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
setBackupMessage(`Backup: ${info.name}`);
api.openUrl("https://github.com/openclaw/openclaw/releases");
})
.catch(() => setBackupMessage("Backup failed"))
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
.finally(() => setBackingUp(false));
}}
>
@@ -246,12 +228,12 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
api.setGlobalModel(val === "__none__" ? null : val)
.then(() => api.getStatusLight())
.then(setStatus)
.catch(() => {})
.catch((e) => showToast?.(String(e), "error"))
.finally(() => setSavingModel(false));
}}
disabled={savingModel}
>
<SelectTrigger className="h-8 text-sm">
<SelectTrigger size="sm" className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -272,14 +254,19 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
</CardContent>
</Card>
{/* Agents Overview grouped by identity */}
{/* Agents Overview -- grouped by identity */}
<div className="flex items-center justify-between mt-6 mb-3">
<h3 className="text-lg font-semibold">Agents</h3>
<Button size="sm" variant="outline" onClick={() => setShowCreateAgent(true)}>
+ New Agent
</Button>
</div>
{agentGroups.length === 0 ? (
{agents === null ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : agentGroups.length === 0 ? (
<p className="text-muted-foreground">No agents found.</p>
) : (
<div className="space-y-3">
@@ -351,47 +338,125 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
{recipes.map((recipe) => (
<Card
<RecipeCard
key={recipe.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => onCook?.(recipe.id)}
>
<CardContent>
<strong>{recipe.name}</strong>
<div className="text-sm text-muted-foreground mt-1.5">
{recipe.description}
</div>
<div className="text-xs text-muted-foreground mt-2">
{recipe.steps.length} step{recipe.steps.length !== 1 ? "s" : ""} &middot; {recipe.difficulty}
</div>
</CardContent>
</Card>
recipe={recipe}
onCook={() => onCook?.(recipe.id)}
compact
/>
))}
</div>
)}
{/* Recent Activity */}
<h3 className="text-lg font-semibold mt-6 mb-3">Recent Activity</h3>
{history.length === 0 ? (
<p className="text-muted-foreground">No recent activity.</p>
{/* Backups */}
<div className="flex items-center justify-between mt-6 mb-3">
<h3 className="text-lg font-semibold">Backups</h3>
<Button
size="sm"
variant="outline"
disabled={backingUp}
onClick={() => {
setBackingUp(true);
setBackupMessage("");
api.backupBeforeUpgrade()
.then((info) => {
setBackupMessage(`Created backup: ${info.name}`);
refreshBackups();
})
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
.finally(() => setBackingUp(false));
}}
>
{backingUp ? "Creating..." : "Create Backup"}
</Button>
</div>
{backupMessage && (
<p className="text-sm text-muted-foreground mb-2">{backupMessage}</p>
)}
{backups === null ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : backups.length === 0 ? (
<p className="text-muted-foreground text-sm">No backups available.</p>
) : (
<div className="flex flex-col gap-2">
{history.map((item) => (
<Card key={item.id}>
<CardContent className="flex justify-between items-center">
<div className="space-y-2">
{backups.map((backup) => (
<Card key={backup.name}>
<CardContent className="flex items-center justify-between">
<div>
<span className="font-medium">{item.recipeId || "manual change"}</span>
<span className="text-sm text-muted-foreground ml-2.5">
{item.source}
</span>
<div className="font-medium text-sm">{backup.name}</div>
<div className="text-xs text-muted-foreground">
{formatTime(backup.createdAt)} {formatBytes(backup.sizeBytes)}
</div>
</div>
<div className="flex items-center gap-2.5">
{item.canRollback && (
<span className="text-xs text-muted-foreground">rollback available</span>
)}
<span className="text-sm text-muted-foreground">
{item.createdAt}
</span>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => api.openUrl(backup.path)}
>
Show
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
Restore
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
<AlertDialogDescription>
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
api.restoreFromBackup(backup.name)
.then((msg) => setBackupMessage(msg))
.catch((e) => setBackupMessage(`Restore failed: ${e}`));
}}
>
Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete backup "{backup.name}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
api.deleteBackup(backup.name)
.then(() => {
setBackupMessage(`Deleted backup "${backup.name}"`);
refreshBackups();
})
.catch((e) => setBackupMessage(`Delete failed: ${e}`));
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
@@ -400,66 +465,12 @@ export function Home({ onCook }: { onCook?: (recipeId: string, source?: string)
)}
{/* Create Agent Dialog */}
<Dialog open={showCreateAgent} onOpenChange={setShowCreateAgent}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Agent ID</Label>
<Input
placeholder="e.g. my-agent"
value={newAgentId}
onChange={(e) => setNewAgentId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Letters, numbers, hyphens, and underscores only.
</p>
</div>
<div className="space-y-1.5">
<Label>Model</Label>
<Select
value={newAgentModel || "__default__"}
onValueChange={(val) => setNewAgentModel(val === "__default__" ? "" : val)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
<span className="text-muted-foreground">use global default</span>
</SelectItem>
{modelProfiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.provider}/{p.model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="independent-agent"
checked={newAgentIndependent}
onCheckedChange={(checked) => setNewAgentIndependent(checked === true)}
/>
<Label htmlFor="independent-agent">Independent agent (separate workspace)</Label>
</div>
{createAgentError && (
<p className="text-sm text-destructive">{createAgentError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateAgent(false)}>
Cancel
</Button>
<Button onClick={handleCreateAgent} disabled={creatingAgent}>
{creatingAgent ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CreateAgentDialog
open={showCreateAgent}
onOpenChange={setShowCreateAgent}
modelProfiles={modelProfiles}
onCreated={() => refreshAgents()}
/>
</div>
);
}

View File

@@ -26,7 +26,7 @@ export function Recipes({
setLoadedSource(value || undefined);
setRecipes(r);
})
.catch(() => {})
.catch((e) => console.error("Failed to load recipes:", e))
.finally(() => setIsLoading(false));
};

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent } from "react";
import { api } from "@/lib/api";
import type { BackupInfo, ModelCatalogProvider, ModelProfile, ProviderAuthSuggestion, ResolvedApiKey } from "@/lib/types";
import type { ModelCatalogProvider, ModelProfile, ProviderAuthSuggestion, ResolvedApiKey } from "@/lib/types";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -113,18 +113,6 @@ function AutocompleteField({
);
}
function formatBytes(bytes: number) {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let index = 0;
let value = bytes;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(1)} ${units[index]}`;
}
export function Settings({ onDataChange }: { onDataChange?: () => void }) {
const [profiles, setProfiles] = useState<ModelProfile[]>([]);
const [catalog, setCatalog] = useState<ModelCatalogProvider[]>([]);
@@ -132,27 +120,20 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
const [form, setForm] = useState<ProfileForm>(emptyForm());
const [message, setMessage] = useState("");
const [authSuggestion, setAuthSuggestion] = useState<ProviderAuthSuggestion | null>(null);
const [backups, setBackups] = useState<BackupInfo[]>([]);
const [backupMessage, setBackupMessage] = useState("");
const [catalogRefreshed, setCatalogRefreshed] = useState(false);
// Load profiles and API keys immediately (fast)
const refreshProfiles = () => {
api.listModelProfiles().then(setProfiles).catch(() => {});
api.resolveApiKeys().then(setApiKeys).catch(() => {});
api.listModelProfiles().then(setProfiles).catch((e) => console.error("Failed to load profiles:", e));
api.resolveApiKeys().then(setApiKeys).catch((e) => console.error("Failed to resolve API keys:", e));
};
useEffect(refreshProfiles, []);
// Load catalog from cache instantly (no CLI calls)
useEffect(() => {
api.getCachedModelCatalog().then(setCatalog).catch(() => {});
}, []);
// Load backups
useEffect(() => {
api.listBackups().then(setBackups).catch(() => {});
api.getCachedModelCatalog().then(setCatalog).catch((e) => console.error("Failed to load model catalog:", e));
}, []);
// Refresh catalog from CLI when user focuses provider/model input
@@ -161,7 +142,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
setCatalogRefreshed(true);
api.refreshModelCatalog().then((fresh) => {
if (fresh.length > 0) setCatalog(fresh);
}).catch(() => {});
}).catch((e) => console.error("Failed to refresh model catalog:", e));
};
// Check for existing auth when provider changes (only for new profiles)
@@ -172,7 +153,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
}
api.resolveProviderAuth(form.provider)
.then(setAuthSuggestion)
.catch(() => setAuthSuggestion(null));
.catch((e) => { console.error("Failed to resolve provider auth:", e); setAuthSuggestion(null); });
}, [form.provider, form.id]);
const maskedKeyMap = useMemo(() => {
@@ -216,7 +197,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
refreshProfiles();
onDataChange?.();
})
.catch(() => setMessage("Save failed"));
.catch((e) => setMessage(`Save failed: ${e}`));
};
const editProfile = (profile: ModelProfile) => {
@@ -242,7 +223,7 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
refreshProfiles();
onDataChange?.();
})
.catch(() => setMessage("Delete failed"));
.catch((e) => setMessage(`Delete failed: ${e}`));
};
return (
@@ -460,97 +441,6 @@ export function Settings({ onDataChange }: { onDataChange?: () => void }) {
{message && (
<p className="text-sm text-muted-foreground mt-3">{message}</p>
)}
{/* Backups */}
<h3 className="text-lg font-semibold mt-6 mb-3">Backups</h3>
{backups.length === 0 ? (
<p className="text-muted-foreground text-sm">No backups available.</p>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<Card key={backup.name}>
<CardContent className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">{backup.name}</div>
<div className="text-xs text-muted-foreground">
{backup.createdAt} {formatBytes(backup.sizeBytes)}
</div>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => api.openUrl(backup.path)}
>
Show
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
Restore
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
<AlertDialogDescription>
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
api.restoreFromBackup(backup.name)
.then((msg) => setBackupMessage(msg))
.catch(() => setBackupMessage("Restore failed"));
}}
>
Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete backup "{backup.name}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
api.deleteBackup(backup.name)
.then(() => {
setBackupMessage(`Deleted backup "${backup.name}"`);
api.listBackups().then(setBackups).catch(() => {});
})
.catch(() => setBackupMessage("Delete failed"));
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
))}
</div>
)}
{backupMessage && (
<p className="text-sm text-muted-foreground mt-2">{backupMessage}</p>
)}
</section>
);
}