feat(ui): add toast notifications and loading spinners to all save/mutation buttons

- Add userFriendlyError() helper to hide 5xx errors from users
- Add Loader2 animate-spin to all save/update/create/delete buttons
- Add useMinLoading(600ms) to StickySaveBar for visible feedback
- Move toast notifications from components into hooks (single source)
- Remove inline saved/saveError state in favor of toast system
- Add toast to all mutation hooks: agents, channels, skills, builtin-tools,
  sessions, teams, storage, tts, cron, providers, config
- Add i18n toast keys for en/vi/zh across all namespaces
- Deduplicate toast calls in team board components
This commit is contained in:
viettranx committed 2026-03-20 15:06:57 +07:00
1 parent 8a3bbab4aa
commit eb318ad8c3
61 files changed
+521 -368

No files matched your search

@@ -1,50 +1,34 @@
import { Save, Check, AlertCircle } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useMinLoading } from "@/hooks/use-min-loading";
interface StickySaveBarProps {
onSave: () => void;
saving: boolean;
saved: boolean;
error?: string | null;
disabled?: boolean;
label?: string;
savingLabel?: string;
savedLabel?: string;
}
/** Sticky footer bar with save button, saving spinner, and success/error feedback. */
/** Sticky footer bar with save button and loading spinner. Toast handles success/error feedback. */
export function StickySaveBar({
onSave,
saving,
saved,
error,
disabled,
label,
savingLabel,
savedLabel,
}: StickySaveBarProps) {
const { t } = useTranslation("common");
const showSpin = useMinLoading(saving, 600);
const resolvedLabel = label ?? t("save");
const resolvedSavingLabel = savingLabel ?? t("saving");
const resolvedSavedLabel = savedLabel ?? t("saved");
return (
<div className="sticky bottom-0 z-10 -mx-3 mt-6 border-t bg-background/80 px-3 py-3 backdrop-blur-sm sm:-mx-4 sm:px-4">
{error && (
<div className="mb-2 flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {resolvedSavedLabel}
</span>
)}
<Button onClick={onSave} disabled={saving || disabled}>
{!saving && <Save className="h-4 w-4" />}
{saving ? resolvedSavingLabel : resolvedLabel}
<Button onClick={onSave} disabled={saving || showSpin || disabled}>
{showSpin ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{showSpin ? resolvedSavingLabel : resolvedLabel}
</Button>
</div>
</div>
+5 -1
View File
@@ -506,6 +506,10 @@
"updateFailed": "Failed to update agent",
"deleted": "Agent deleted",
"deleteFailed": "Failed to delete agent",
"unknownError": "Unknown error"
"unknownError": "Unknown error",
"skillGranted": "Skill granted",
"skillGrantFailed": "Failed to grant skill",
"skillRevoked": "Skill revoked",
"skillRevokeFailed": "Failed to revoke skill"
}
}
+4 -1
View File
@@ -118,5 +118,8 @@
"deleteFile": "Delete file",
"loadMore": "Load more...",
"viewCard": "Card view",
"viewList": "List view"
"viewList": "List view",
"errors": {
"serverError": "Something went wrong. Please try again later."
}
}
+8
View File
@@ -33,5 +33,13 @@
"hide": "hide",
"systemMessage": "System Message",
"messages": "messages"
},
"toast": {
"deleted": "Session deleted",
"deleteFailed": "Failed to delete session",
"reset": "Session reset",
"resetFailed": "Failed to reset session",
"updated": "Session updated",
"updateFailed": "Failed to update session"
}
}
+6
View File
@@ -102,5 +102,11 @@
"version": "Version:",
"current": "(current)",
"noContent": "No content available."
},
"toast": {
"updated": "Skill updated",
"updateFailed": "Failed to update skill",
"deleted": "Skill deleted",
"deleteFailed": "Failed to delete skill"
}
}
+4
View File
@@ -13,5 +13,9 @@
"undone": " This action cannot be undone.",
"deleting": "Deleting...",
"confirmLabel": "Delete"
},
"toast": {
"deleted": "File deleted",
"deleteFailed": "Failed to delete file"
}
}
+9 -1
View File
@@ -292,9 +292,17 @@
"taskCreated": "Task created",
"taskDeleted": "Task deleted",
"taskAssigned": "Task assigned",
"taskApproved": "Task approved",
"taskRejected": "Task rejected",
"tasksBulkDeleted": "Tasks deleted",
"failedCreateTask": "Failed to create task",
"failedDeleteTask": "Failed to delete task",
"failedAssignTask": "Failed to assign task"
"failedAssignTask": "Failed to assign task",
"failedApproveTask": "Failed to approve task",
"failedRejectTask": "Failed to reject task",
"failedBulkDeleteTasks": "Failed to delete tasks",
"workspaceFileDeleted": "File deleted",
"failedDeleteWorkspaceFile": "Failed to delete file"
},
"board": {
"kanban": "Board",
+5 -1
View File
@@ -506,6 +506,10 @@
"updateFailed": "Không thể cập nhật agent",
"deleted": "Đã xóa agent",
"deleteFailed": "Không thể xóa agent",
"unknownError": "Lỗi không xác định"
"unknownError": "Lỗi không xác định",
"skillGranted": "Đã cấp skill",
"skillGrantFailed": "Không thể cấp skill",
"skillRevoked": "Đã thu hồi skill",
"skillRevokeFailed": "Không thể thu hồi skill"
}
}
+4 -1
View File
@@ -118,5 +118,8 @@
"deleteFile": "Xóa tệp",
"loadMore": "Tải thêm...",
"viewCard": "Dạng thẻ",
"viewList": "Dạng danh sách"
"viewList": "Dạng danh sách",
"errors": {
"serverError": "Đã xảy ra lỗi. Vui lòng thử lại sau."
}
}
+8
View File
@@ -33,5 +33,13 @@
"hide": "ẩn",
"systemMessage": "Tin nhắn hệ thống",
"messages": "tin nhắn"
},
"toast": {
"deleted": "Đã xóa session",
"deleteFailed": "Không thể xóa session",
"reset": "Đã đặt lại session",
"resetFailed": "Không thể đặt lại session",
"updated": "Đã cập nhật session",
"updateFailed": "Không thể cập nhật session"
}
}
+6
View File
@@ -102,5 +102,11 @@
"version": "Phiên bản:",
"current": "(hiện tại)",
"noContent": "Không có nội dung."
},
"toast": {
"updated": "Đã cập nhật skill",
"updateFailed": "Không thể cập nhật skill",
"deleted": "Đã xóa skill",
"deleteFailed": "Không thể xóa skill"
}
}
+4
View File
@@ -13,5 +13,9 @@
"undone": " Hành động này không thể hoàn tác.",
"deleting": "Đang xóa...",
"confirmLabel": "Xóa"
},
"toast": {
"deleted": "Đã xóa file",
"deleteFailed": "Không thể xóa file"
}
}
+9 -1
View File
@@ -292,9 +292,17 @@
"taskCreated": "Đã tạo nhiệm vụ",
"taskDeleted": "Đã xoá task",
"taskAssigned": "Đã gán nhiệm vụ",
"taskApproved": "Đã phê duyệt task",
"taskRejected": "Đã từ chối task",
"tasksBulkDeleted": "Đã xóa các task",
"failedCreateTask": "Không thể tạo nhiệm vụ",
"failedDeleteTask": "Xoá task thất bại",
"failedAssignTask": "Không thể gán nhiệm vụ"
"failedAssignTask": "Không thể gán nhiệm vụ",
"failedApproveTask": "Không thể phê duyệt task",
"failedRejectTask": "Không thể từ chối task",
"failedBulkDeleteTasks": "Không thể xóa các task",
"workspaceFileDeleted": "Đã xóa file",
"failedDeleteWorkspaceFile": "Không thể xóa file"
},
"board": {
"kanban": "Bảng",
+5 -1
View File
@@ -506,6 +506,10 @@
"updateFailed": "更新代理失败",
"deleted": "代理已删除",
"deleteFailed": "删除代理失败",
"unknownError": "未知错误"
"unknownError": "未知错误",
"skillGranted": "技能已授权",
"skillGrantFailed": "授权技能失败",
"skillRevoked": "技能已撤销",
"skillRevokeFailed": "撤销技能失败"
}
}
+4 -1
View File
@@ -118,5 +118,8 @@
"deleteFile": "删除文件",
"loadMore": "加载更多...",
"viewCard": "卡片视图",
"viewList": "列表视图"
"viewList": "列表视图",
"errors": {
"serverError": "出了点问题,请稍后重试。"
}
}
+8
View File
@@ -33,5 +33,13 @@
"hide": "隐藏",
"systemMessage": "系统消息",
"messages": "条消息"
},
"toast": {
"deleted": "Session已删除",
"deleteFailed": "删除Session失败",
"reset": "Session已重置",
"resetFailed": "重置Session失败",
"updated": "Session已更新",
"updateFailed": "更新Session失败"
}
}
+6
View File
@@ -102,5 +102,11 @@
"version": "版本:",
"current": "(当前)",
"noContent": "无可用内容。"
},
"toast": {
"updated": "技能已更新",
"updateFailed": "更新技能失败",
"deleted": "技能已删除",
"deleteFailed": "删除技能失败"
}
}
+4
View File
@@ -13,5 +13,9 @@
"undone": " 此操作不可撤销。",
"deleting": "删除中...",
"confirmLabel": "删除"
},
"toast": {
"deleted": "文件已删除",
"deleteFailed": "删除文件失败"
}
}
+9 -1
View File
@@ -292,9 +292,17 @@
"taskCreated": "任务已创建",
"taskDeleted": "任务已删除",
"taskAssigned": "任务已分配",
"taskApproved": "任务已审批",
"taskRejected": "任务已拒绝",
"tasksBulkDeleted": "任务已批量删除",
"failedCreateTask": "创建任务失败",
"failedDeleteTask": "删除任务失败",
"failedAssignTask": "分配任务失败"
"failedAssignTask": "分配任务失败",
"failedApproveTask": "审批任务失败",
"failedRejectTask": "拒绝任务失败",
"failedBulkDeleteTasks": "批量删除任务失败",
"workspaceFileDeleted": "文件已删除",
"failedDeleteWorkspaceFile": "删除文件失败"
},
"board": {
"kanban": "看板",
+14
View File
@@ -0,0 +1,14 @@
import { ApiError, ErrorCodes } from "@/api/errors";
import i18n from "@/i18n";
/** Map API errors to user-friendly messages. Hides 5xx/INTERNAL details from users. */
export function userFriendlyError(err: unknown): string {
if (err instanceof ApiError) {
if (err.code === ErrorCodes.INTERNAL || err.code === "HTTP_ERROR" || err.code === ErrorCodes.UNAVAILABLE) {
return i18n.t("common:errors.serverError");
}
return err.message;
}
if (err instanceof Error) return err.message;
return i18n.t("common:errors.serverError");
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Save, Settings, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
@@ -63,11 +63,9 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
}, [open]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
// Only send the keys this dialog owns to avoid overwriting keys managed by
// the overview tab. The backend does a full column replace, so we must read
@@ -92,8 +90,8 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
other_config: otherBase,
});
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("config.failedToSave"));
} catch {
// toast shown by hook — keep dialog open
} finally {
setSaving(false);
}
@@ -143,19 +141,14 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
</div>
{/* Footer */}
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("create.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("config.saving") : t("config.saveConfig")}
</Button>
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("create.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("config.saving") : t("config.saveConfig")}
</Button>
</div>
</DialogContent>
</Dialog>
@@ -79,6 +79,8 @@ export function AgentFilesTab({
try {
await onSetFile(selectedFile, content);
setDirty(false);
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -1,6 +1,8 @@
import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { Save, Check, AlertCircle, Users, FileText, Search, UserPlus } from "lucide-react";
import { Save, Loader2, Users, FileText, Search, UserPlus } from "lucide-react";
import { toast } from "@/stores/use-toast-store";
import { userFriendlyError } from "@/lib/error-utils";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -20,14 +22,11 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
const [content, setContent] = useState("");
const [originalContent, setOriginalContent] = useState("");
const [loadingFiles, setLoadingFiles] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!selected) return;
let cancelled = false;
setLoadingFiles(true);
setError(null);
getFiles(selected).then((files) => {
if (cancelled) return;
const userFile = files.find((f) => f.file_name === "USER.md");
@@ -35,7 +34,7 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
setContent(c);
setOriginalContent(c);
}).catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : t("instances.loading"));
if (!cancelled) toast.error(t("instances.loading"), userFriendlyError(err));
}).finally(() => {
if (!cancelled) setLoadingFiles(false);
});
@@ -44,15 +43,11 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
const handleSave = async () => {
if (!selected) return;
setError(null);
setSaved(false);
try {
await setFile(selected, "USER.md", content);
setOriginalContent(content);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : t("instances.saving"));
} catch {
// toast shown by hook
}
};
@@ -127,20 +122,9 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
placeholder="(empty)"
style={{ minHeight: 300 }}
/>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {t("instances.saved")}
</span>
)}
<Button onClick={handleSave} disabled={saving || !isDirty} size="sm">
{!saving && <Save className="h-4 w-4" />}
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("instances.saving") : t("instances.save")}
</Button>
</div>
@@ -55,14 +55,10 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa
// Save state
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [llmSaveBlocked, setLlmSaveBlocked] = useState(false);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
const updatedOtherConfig = {
...otherCfg,
@@ -89,10 +85,8 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa
? { profile: tools.profile, allow: tools.allow, deny: tools.deny, alsoAllow: tools.alsoAllow, byProvider: tools.byProvider }
: {},
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("general.failedToSave"));
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -163,12 +157,9 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
disabled={llmSaveBlocked}
label={t("general.saveChanges")}
savingLabel={t("general.saving")}
savedLabel={t("general.saved")}
/>
</div>
);
@@ -1,6 +1,6 @@
import { type ReactNode, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { FILE_DESCRIPTIONS } from "./file-utils";
@@ -90,7 +90,7 @@ export function FileEditor({
{headerActions}
{canEdit && (
<Button size="sm" onClick={onSave} disabled={!dirty || saving}>
{!saving && <Save className="h-3.5 w-3.5" />}
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{saving ? t("files.saving") : t("files.save")}
</Button>
)}
@@ -127,28 +127,32 @@ export function HeartbeatConfigDialog({
};
const handleSave = async () => {
const clampedMin = Math.max(5, intervalMin);
await update({
enabled,
intervalSec: clampedMin * 60,
ackMaxChars: ackMaxChars,
maxRetries: maxRetries,
isolatedSession: isolatedSession,
lightContext: lightContext,
activeHoursStart: activeHoursStart || undefined,
activeHoursEnd: activeHoursEnd || undefined,
timezone: timezone || undefined,
channel: channel || undefined,
chatId: chatId || undefined,
model: hbModel || undefined,
providerName: hbProvider || undefined,
});
if (checklist !== originalChecklist) {
await setChecklist(checklist);
setOriginalChecklist(checklist);
try {
const clampedMin = Math.max(5, intervalMin);
await update({
enabled,
intervalSec: clampedMin * 60,
ackMaxChars: ackMaxChars,
maxRetries: maxRetries,
isolatedSession: isolatedSession,
lightContext: lightContext,
activeHoursStart: activeHoursStart || undefined,
activeHoursEnd: activeHoursEnd || undefined,
timezone: timezone || undefined,
channel: channel || undefined,
chatId: chatId || undefined,
model: hbModel || undefined,
providerName: hbProvider || undefined,
});
if (checklist !== originalChecklist) {
await setChecklist(checklist);
setOriginalChecklist(checklist);
}
await refresh();
onOpenChange(false);
} catch {
// toast shown by hook — keep dialog open
}
await refresh();
onOpenChange(false);
};
return (
@@ -3,6 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp, useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "@/stores/use-toast-store";
import i18n from "@/i18n";
import { userFriendlyError } from "@/lib/error-utils";
import type { AgentData, BootstrapFile } from "@/types/agent";
interface AgentDetailData {
@@ -70,8 +73,14 @@ export function useAgentDetail(agentId: string | undefined) {
const updateAgent = useCallback(
async (updates: Record<string, unknown>) => {
if (!agentId) return;
await http.put(`/v1/agents/${agentId}`, updates);
await invalidate();
try {
await http.put(`/v1/agents/${agentId}`, updates);
await invalidate();
toast.success(i18n.t("agents:toast.updated"));
} catch (err) {
toast.error(i18n.t("agents:toast.updateFailed"), userFriendlyError(err));
throw err;
}
},
[agentId, http, invalidate],
);
@@ -91,12 +100,18 @@ export function useAgentDetail(agentId: string | undefined) {
const setFile = useCallback(
async (name: string, content: string) => {
if (!agent || !ws.isConnected) return;
await ws.call(Methods.AGENTS_FILES_SET, {
agentId: agent.agent_key,
name,
content,
});
await invalidate();
try {
await ws.call(Methods.AGENTS_FILES_SET, {
agentId: agent.agent_key,
name,
content,
});
await invalidate();
toast.success(i18n.t("agents:toast.updated"));
} catch (err) {
toast.error(i18n.t("agents:toast.updateFailed"), userFriendlyError(err));
throw err;
}
},
[agent, ws, invalidate],
);
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { toast } from "@/stores/use-toast-store";
import { userFriendlyError } from "@/lib/error-utils";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
@@ -68,7 +69,7 @@ export function useAgentHeartbeat(agentId: string) {
);
setConfig(res.heartbeat);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load heartbeat";
const msg = userFriendlyError(err);
setError(msg);
toast.error(msg);
} finally {
@@ -125,7 +126,7 @@ export function useAgentHeartbeat(agentId: string) {
// Wait for backend to compute nextRunAt, then refresh.
setTimeout(() => refresh(), 2000);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to toggle heartbeat";
const msg = userFriendlyError(err);
toast.error(msg);
} finally {
setSaving(false);
@@ -148,7 +149,7 @@ export function useAgentHeartbeat(agentId: string) {
// Delayed refresh to pick up any backend-computed state (e.g. nextRunAt).
setTimeout(() => refresh(), 2000);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save heartbeat";
const msg = userFriendlyError(err);
setError(msg);
toast.error(msg);
throw err;
@@ -166,7 +167,7 @@ export function useAgentHeartbeat(agentId: string) {
await ws.call(Methods.HEARTBEAT_TEST, { agentId });
toast.success("Test run triggered");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to trigger test";
const msg = userFriendlyError(err);
toast.error(msg);
} finally {
setSaving(false);
@@ -2,6 +2,9 @@ import { useCallback, useState } 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 i18n from "@/i18n";
import { userFriendlyError } from "@/lib/error-utils";
export interface UserInstance {
user_id: string;
@@ -51,6 +54,10 @@ export function useAgentInstances(agentId: string) {
{ content },
);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instances(agentId) });
toast.success(i18n.t("agents:toast.updated"));
} catch (err) {
toast.error(i18n.t("agents:toast.updateFailed"), userFriendlyError(err));
throw err;
} finally {
setSaving(false);
}
@@ -3,6 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { SkillWithGrant } from "@/types/skill";
import { toast } from "@/stores/use-toast-store";
import i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
export function useAgentSkills(agentId: string) {
const http = useHttp();
@@ -36,6 +39,10 @@ export function useAgentSkills(agentId: string) {
optimisticToggle(skillId, true);
try {
await http.post(`/v1/skills/${skillId}/grants/agent`, { agent_id: agentId });
toast.success(i18next.t("agents:toast.skillGranted"));
} catch (err) {
toast.error(i18next.t("agents:toast.skillGrantFailed"), userFriendlyError(err));
throw err;
} finally {
await invalidate();
}
@@ -48,6 +55,10 @@ export function useAgentSkills(agentId: string) {
optimisticToggle(skillId, false);
try {
await http.delete(`/v1/skills/${skillId}/grants/agent/${agentId}`);
toast.success(i18next.t("agents:toast.skillRevoked"));
} catch (err) {
toast.error(i18next.t("agents:toast.skillRevokeFailed"), userFriendlyError(err));
throw err;
} finally {
await invalidate();
}
+4 -3
View File
@@ -6,6 +6,7 @@ import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "@/stores/use-toast-store";
import i18n from "@/i18n";
import { userFriendlyError } from "@/lib/error-utils";
import type { AgentData } from "@/types/agent";
interface AgentInfoWs {
@@ -69,7 +70,7 @@ export function useAgents() {
toast.success(i18n.t("agents:toast.created"), `${data.display_name || data.agent_key || "Agent"} has been added`);
return res;
} catch (err) {
toast.error(i18n.t("agents:toast.createFailed"), err instanceof Error ? err.message : i18n.t("agents:toast.unknownError"));
toast.error(i18n.t("agents:toast.createFailed"), userFriendlyError(err));
throw err;
}
},
@@ -83,7 +84,7 @@ export function useAgents() {
await invalidate();
toast.success(i18n.t("agents:toast.updated"), `${data.display_name || data.agent_key || "Agent"} has been updated`);
} catch (err) {
toast.error(i18n.t("agents:toast.updateFailed"), err instanceof Error ? err.message : i18n.t("agents:toast.unknownError"));
toast.error(i18n.t("agents:toast.updateFailed"), userFriendlyError(err));
throw err;
}
},
@@ -97,7 +98,7 @@ export function useAgents() {
await invalidate();
toast.success(i18n.t("agents:toast.deleted"));
} catch (err) {
toast.error(i18n.t("agents:toast.deleteFailed"), err instanceof Error ? err.message : i18n.t("agents:toast.unknownError"));
toast.error(i18n.t("agents:toast.deleteFailed"), userFriendlyError(err));
throw err;
}
},
@@ -9,6 +9,7 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
import { MEDIA_TOOLS } from "./media-provider-params-schema";
@@ -110,14 +111,20 @@ function JsonSettingsForm({
const handleSave = async () => {
if (!tool) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(json);
} catch {
setError(t("builtin.jsonDialog.invalidJson"));
return;
}
setSaving(true);
setError("");
try {
const parsed = JSON.parse(json);
setSaving(true);
setError("");
await onSave(tool.name, parsed);
onOpenChange(false);
} catch (e) {
setError(e instanceof SyntaxError ? t("builtin.jsonDialog.invalidJson") : String(e));
} catch {
// toast shown by hook — keep dialog open
} finally {
setSaving(false);
}
@@ -151,6 +158,7 @@ function JsonSettingsForm({
{t("builtin.jsonDialog.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving || !validJson}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.jsonDialog.saving") : t("builtin.jsonDialog.save")}
</Button>
</DialogFooter>
@@ -2,6 +2,9 @@ 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 i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
export interface BuiltinToolData {
name: string;
@@ -36,8 +39,14 @@ export function useBuiltinTools() {
const updateTool = useCallback(
async (name: string, data: { enabled?: boolean; settings?: Record<string, unknown> }) => {
await http.put(`/v1/tools/builtin/${name}`, data);
await invalidate();
try {
await http.put(`/v1/tools/builtin/${name}`, data);
await invalidate();
toast.success(i18next.t("tools:builtin.settingsDialog.toast.saved"));
} catch (err) {
toast.error(i18next.t("tools:builtin.settingsDialog.toast.failed"), userFriendlyError(err));
throw err;
}
},
[http, invalidate],
);
@@ -7,6 +7,7 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -49,6 +50,8 @@ export function KGSettingsForm({ initialSettings, onSave, onCancel }: Props) {
setSaving(true);
try {
await onSave(settings as unknown as Record<string, unknown>);
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -110,6 +113,7 @@ export function KGSettingsForm({ initialSettings, onSave, onCancel }: Props) {
<DialogFooter>
<Button variant="outline" onClick={onCancel}>{t("builtin.kgSettings.cancel")}</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.kgSettings.saving") : t("builtin.kgSettings.save")}
</Button>
</DialogFooter>
@@ -17,7 +17,7 @@ import {
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2, ChevronDown, ChevronUp, Plus } from "lucide-react";
import { GripVertical, Trash2, ChevronDown, ChevronUp, Plus, Loader2 } from "lucide-react";
import { uniqueId } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -415,6 +415,8 @@ export function MediaProviderChainForm({
try {
const serialized = entries.map(({ id: _id, ...rest }) => rest);
await onSave({ providers: serialized });
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -464,6 +466,7 @@ export function MediaProviderChainForm({
{t("builtin.mediaChain.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.mediaChain.saving") : t("builtin.mediaChain.save")}
</Button>
</DialogFooter>
@@ -17,7 +17,7 @@ import {
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { GripVertical, Loader2 } from "lucide-react";
import { uniqueId } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -198,6 +198,8 @@ export function WebFetchExtractorChainForm({ initialSettings, onSave, onCancel }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const serialized = entries.map(({ id: _id, ...rest }) => rest);
await onSave({ extractors: serialized });
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -231,6 +233,7 @@ export function WebFetchExtractorChainForm({ initialSettings, onSave, onCancel }
{t("builtin.extractorChain.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.extractorChain.saving") : t("builtin.extractorChain.save")}
</Button>
</DialogFooter>
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Save, Settings, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -60,13 +60,11 @@ export function ChannelAdvancedDialog({
const [values, setValues] = useState<Record<string, unknown>>(() => deriveInitialValues(instance));
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Re-sync local state when dialog opens
useEffect(() => {
if (!open) return;
setValues(deriveInitialValues(instance));
setSaveError(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
@@ -76,7 +74,6 @@ export function ChannelAdvancedDialog({
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
const existingConfig = (instance.config ?? {}) as Record<string, unknown>;
const cleanAdvanced = Object.fromEntries(
@@ -86,8 +83,7 @@ export function ChannelAdvancedDialog({
const merged = { ...existingConfig, ...cleanAdvanced };
await onUpdate({ config: merged });
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("form.errors.failedSaveConfig"));
} catch { // toast shown by hook
} finally {
setSaving(false);
}
@@ -189,19 +185,14 @@ export function ChannelAdvancedDialog({
</div>
{/* Footer */}
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("form.saving") : t("detail.config.saveConfig")}
</Button>
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("form.saving") : t("detail.config.saveConfig")}
</Button>
</div>
</DialogContent>
</Dialog>
@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Save, Check, AlertCircle } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ChannelInstanceData } from "@/types/channel";
import { credentialsSchema } from "../channel-schemas";
@@ -15,8 +15,6 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
const { t } = useTranslation("channels");
const [values, setValues] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const fields = credentialsSchema[instance.channel_type] ?? [];
@@ -28,20 +26,12 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
const cleanCreds = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
if (Object.keys(cleanCreds).length === 0) {
setSaveError(t("detail.credentials.noCredentials"));
return;
}
if (Object.keys(cleanCreds).length === 0) return;
setSaving(true);
setSaveError(null);
setSaved(false);
try {
await onUpdate({ credentials: cleanCreds });
setSaved(true);
setValues({});
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("detail.credentials.failedSave"));
} catch { // toast shown by hook
} finally {
setSaving(false);
}
@@ -71,20 +61,9 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
isEdit
/>
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {t("detail.credentials.saved")}
</span>
)}
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("detail.credentials.saving") : t("detail.credentials.updateCredentials")}
</Button>
</div>
@@ -44,8 +44,6 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral
const [policyValues, setPolicyValues] = useState<Record<string, unknown>>(initialPolicyValues);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const handlePolicyChange = useCallback((key: string, value: unknown) => {
setPolicyValues((prev) => ({ ...prev, [key]: value }));
@@ -53,8 +51,6 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
// Merge policy values into existing config, preserving other keys (groups, advanced)
const cleanPolicies = Object.fromEntries(
@@ -67,10 +63,8 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral
enabled,
config: mergedConfig,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("form.errors.failedSave"));
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -146,11 +140,8 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
label={t("detail.general.saveChanges")}
savingLabel={t("detail.general.saving")}
savedLabel={t("detail.general.saved")}
/>
</div>
);
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Check, AlertCircle } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ChannelInstanceData } from "@/types/channel";
import { TelegramGroupOverrides } from "../telegram-group-overrides";
@@ -25,8 +25,6 @@ export function ChannelGroupsTab({ instance, onUpdate, listManagerGroups }: Chan
(config.groups as Record<string, GroupConfigWithTopics>) ?? {},
);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [knownGroups, setKnownGroups] = useState<GroupManagerGroupInfo[]>([]);
const loadKnownGroups = useCallback(async () => {
@@ -47,14 +45,9 @@ export function ChannelGroupsTab({ instance, onUpdate, listManagerGroups }: Chan
);
setSaving(true);
setSaveError(null);
setSaved(false);
try {
await onUpdate({ config: Object.keys(cleanConfig).length > 0 ? cleanConfig : null });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Failed to save");
} catch { // toast shown by hook
} finally {
setSaving(false);
}
@@ -64,20 +57,9 @@ export function ChannelGroupsTab({ instance, onUpdate, listManagerGroups }: Chan
<div className="space-y-6">
<TelegramGroupOverrides groups={groups} onChange={(g) => setGroups(g)} knownGroups={knownGroups} />
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {t("detail.groups.saved")}
</span>
)}
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("detail.groups.saving") : t("detail.groups.saveGroups")}
</Button>
</div>
@@ -2,6 +2,9 @@ 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 i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
import type { ChannelInstanceData } from "@/types/channel";
import type { ChannelContact } from "@/types/contact";
@@ -40,8 +43,14 @@ export function useChannelDetail(instanceId: string | undefined) {
const updateInstance = useCallback(
async (updates: Record<string, unknown>) => {
if (!instanceId) return;
await http.put(`/v1/channels/instances/${instanceId}`, updates);
await invalidate();
try {
await http.put(`/v1/channels/instances/${instanceId}`, updates);
await invalidate();
toast.success(i18next.t("channels:toast.updated"));
} catch (err) {
toast.error(i18next.t("channels:toast.failedUpdate"), userFriendlyError(err));
throw err;
}
},
[instanceId, http, invalidate],
);
@@ -4,6 +4,9 @@ import { useWsEvent } from "@/hooks/use-ws-event";
import { Methods, Events } from "@/api/protocol";
import type { SessionInfo } from "@/types/session";
import { useAuthStore } from "@/stores/use-auth-store";
import { toast } from "@/stores/use-toast-store";
import i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
/**
* Manages the session list for the chat sidebar.
@@ -48,8 +51,14 @@ export function useChatSessions(agentId: string) {
const deleteSession = useCallback(async (key: string) => {
if (!connected) return;
await ws.call(Methods.SESSIONS_DELETE, { key });
await loadSessions();
try {
await ws.call(Methods.SESSIONS_DELETE, { key });
await loadSessions();
toast.success(i18next.t("sessions:toast.deleted"));
} catch (err) {
toast.error(i18next.t("sessions:toast.deleteFailed"), userFriendlyError(err));
throw err;
}
}, [ws, connected, loadSessions]);
// Update session label in-place when backend generates a title.
+3 -2
View File
@@ -6,6 +6,7 @@ import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "@/stores/use-toast-store";
import i18n from "@/i18n";
import { userFriendlyError } from "@/lib/error-utils";
interface ConfigData {
config: Record<string, unknown>;
@@ -54,7 +55,7 @@ export function useConfig() {
await invalidate();
toast.success(i18n.t("config:toast.saved"));
} catch (err) {
const msg = err instanceof Error ? err.message : i18n.t("config:toast.saveFailed");
const msg = userFriendlyError(err);
setError(msg);
toast.error(i18n.t("config:toast.saveFailed"), msg);
throw err;
@@ -78,7 +79,7 @@ export function useConfig() {
await invalidate();
toast.success(i18n.t("config:toast.saved"));
} catch (err) {
const msg = err instanceof Error ? err.message : i18n.t("config:toast.saveFailed");
const msg = userFriendlyError(err);
setError(msg);
toast.error(i18n.t("config:toast.saveFailed"), msg);
throw err;
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Save, ChevronDown, ChevronRight, AlertCircle } from "lucide-react";
import { Save, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -25,13 +25,11 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<AgentsData>(data ?? DEFAULT);
const [dirty, setDirty] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [openSubs, setOpenSubs] = useState<Set<string>>(new Set());
useEffect(() => {
setDraft(data ?? DEFAULT);
setDirty(false);
setSaveError(null);
}, [data]);
const defaults = draft.defaults ?? {};
@@ -246,19 +244,13 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
</div>
</SubSection>
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={async () => {
setSaveError(null);
try { await onSave(draft); } catch (err) { setSaveError(err instanceof Error ? err.message : t("agents.saveError")); }
try { await onSave(draft); } catch { /* toast shown by hook */ }
}} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{saving ? t("saving") : t("save")}
</Button>
</div>
)}
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Save, Settings, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -41,7 +41,6 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
const [wakeHeartbeat, setWakeHeartbeat] = useState(init.wakeHeartbeat);
const [deleteAfterRun, setDeleteAfterRun] = useState(init.deleteAfterRun);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Re-sync when dialog opens
useEffect(() => {
@@ -62,7 +61,6 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
return;
}
setSaving(true);
setSaveError(null);
try {
await onUpdate(job.id, {
schedule: {
@@ -76,8 +74,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
deleteAfterRun,
});
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : tc("save"));
} catch { // toast shown by hook
} finally {
setSaving(false);
}
@@ -179,19 +176,14 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
</div>
{/* Footer */}
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{tc("cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? tc("saving") : tc("save")}
</Button>
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{tc("cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? tc("saving") : tc("save")}
</Button>
</div>
</DialogContent>
</Dialog>
@@ -44,14 +44,10 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
const [enabled, setEnabled] = useState(job.enabled);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSave = async () => {
if (!onUpdate) return;
setSaving(true);
setSaveError(null);
setSaved(false);
try {
let schedule;
if (scheduleKind === "every") {
@@ -67,10 +63,8 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
agentId: agentId.trim() || undefined,
enabled,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("detail.saveFailed"));
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -216,8 +210,6 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
/>
)}
</div>
@@ -8,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
@@ -83,8 +84,8 @@ export function CustomToolFormDialog({ open, onOpenChange, tool, onSubmit }: Cus
enabled,
});
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t("custom.form.saving"));
} catch {
// toast shown by hook — keep dialog open
} finally {
setLoading(false);
}
@@ -160,6 +161,7 @@ export function CustomToolFormDialog({ open, onOpenChange, tool, onSubmit }: Cus
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>{t("custom.form.cancel")}</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? t("custom.form.saving") : tool ? t("custom.form.update") : t("custom.form.create")}
</Button>
</DialogFooter>
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Save, Settings, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -79,11 +79,9 @@ export function ProviderAdvancedDialog({
}, [open]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
const data: ProviderInput = {
name: provider.name,
@@ -104,8 +102,7 @@ export function ProviderAdvancedDialog({
await onUpdate(provider.id, data);
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("form.saving"));
} catch { // toast shown by hook
} finally {
setSaving(false);
}
@@ -255,21 +252,16 @@ export function ProviderAdvancedDialog({
</div>
{/* Footer */}
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("form.cancel")}
<div className="flex items-center justify-end gap-2 pt-4 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("form.cancel")}
</Button>
{!isOAuth && (
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("form.saving") : t("form.save")}
</Button>
{!isOAuth && (
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("form.saving") : t("form.save")}
</Button>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
@@ -37,13 +37,9 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
// Save state
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
const data: ProviderInput = {
name: provider.name,
@@ -56,10 +52,8 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
data.api_key = apiKey;
}
await onUpdate(provider.id, data);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("toast.failedUpdate"));
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
@@ -143,8 +137,6 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
/>
</div>
);
@@ -5,6 +5,9 @@ import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { SessionInfo, SessionPreview, Message } from "@/types/session";
import { toast } from "@/stores/use-toast-store";
import i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
interface UseSessionsOptions {
agentFilter?: string;
@@ -58,8 +61,14 @@ export function useSessions(opts: UseSessionsOptions = {}) {
const deleteSession = useCallback(
async (key: string) => {
if (!ws.isConnected) return;
await ws.call(Methods.SESSIONS_DELETE, { key });
await invalidate();
try {
await ws.call(Methods.SESSIONS_DELETE, { key });
await invalidate();
toast.success(i18next.t("sessions:toast.deleted"));
} catch (err) {
toast.error(i18next.t("sessions:toast.deleteFailed"), userFriendlyError(err));
throw err;
}
},
[ws, invalidate],
);
@@ -67,8 +76,14 @@ export function useSessions(opts: UseSessionsOptions = {}) {
const resetSession = useCallback(
async (key: string) => {
if (!ws.isConnected) return;
await ws.call(Methods.SESSIONS_RESET, { key });
await invalidate();
try {
await ws.call(Methods.SESSIONS_RESET, { key });
await invalidate();
toast.success(i18next.t("sessions:toast.reset"));
} catch (err) {
toast.error(i18next.t("sessions:toast.resetFailed"), userFriendlyError(err));
throw err;
}
},
[ws, invalidate],
);
@@ -76,8 +91,14 @@ export function useSessions(opts: UseSessionsOptions = {}) {
const patchSession = useCallback(
async (key: string, updates: { label?: string; model?: string; metadata?: Record<string, string> }) => {
if (!ws.isConnected) return;
await ws.call(Methods.SESSIONS_PATCH, { key, ...updates });
await invalidate();
try {
await ws.call(Methods.SESSIONS_PATCH, { key, ...updates });
await invalidate();
toast.success(i18next.t("sessions:toast.updated"));
} catch (err) {
toast.error(i18next.t("sessions:toast.updateFailed"), userFriendlyError(err));
throw err;
}
},
[ws, invalidate],
);
+21 -6
View File
@@ -4,6 +4,9 @@ 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 i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
export type { SkillInfo, SkillFile, SkillVersions };
@@ -59,18 +62,30 @@ export function useSkills() {
const updateSkill = useCallback(
async (id: string, updates: Record<string, unknown>) => {
const res = await http.put<{ ok: string }>(`/v1/skills/${id}`, updates);
await invalidate();
return res;
try {
const res = await http.put<{ ok: string }>(`/v1/skills/${id}`, updates);
await invalidate();
toast.success(i18next.t("skills:toast.updated"));
return res;
} catch (err) {
toast.error(i18next.t("skills:toast.updateFailed"), userFriendlyError(err));
throw err;
}
},
[http, invalidate],
);
const deleteSkill = useCallback(
async (id: string) => {
const res = await http.delete<{ ok: string }>(`/v1/skills/${id}`);
await invalidate();
return res;
try {
const res = await http.delete<{ ok: string }>(`/v1/skills/${id}`);
await invalidate();
toast.success(i18next.t("skills:toast.deleted"));
return res;
} catch (err) {
toast.error(i18next.t("skills:toast.deleteFailed"), userFriendlyError(err));
throw err;
}
},
[http, invalidate],
);
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { X, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -36,7 +36,6 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
const [tags, setTags] = useState<string[]>(skill.tags ?? []);
const [tagInput, setTagInput] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setName(skill.name);
@@ -60,12 +59,11 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
const handleSave = async () => {
if (!skill.id) return;
setLoading(true);
setError("");
try {
await onSave(skill.id, { name, description, visibility, tags });
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t("edit.save"));
} catch {
// toast shown by hook — keep dialog open
} finally {
setLoading(false);
}
@@ -143,13 +141,12 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
{t("edit.cancel")}
</Button>
<Button onClick={handleSave} disabled={loading || !name.trim()}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? t("edit.saving") : t("edit.save")}
</Button>
</DialogFooter>
+12 -3
View File
@@ -1,5 +1,8 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useHttp } from "@/hooks/use-ws";
import { toast } from "@/stores/use-toast-store";
import i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
export interface StorageFile {
path: string;
@@ -54,9 +57,15 @@ export function useStorage() {
const deleteFile = useCallback(
async (path: string) => {
await http.delete<{ status: string }>(
`/v1/storage/files/${encodeURIComponent(path)}`,
);
try {
await http.delete<{ status: string }>(
`/v1/storage/files/${encodeURIComponent(path)}`,
);
toast.success(i18next.t("storage:toast.deleted"));
} catch (err) {
toast.error(i18next.t("storage:toast.deleteFailed"), userFriendlyError(err));
throw err;
}
},
[http],
);
@@ -240,10 +240,9 @@ export const BoardContainer = memo(function BoardContainer({
setSingleDeleting(true);
try {
await deleteTaskRef.current(teamId, deleteTargetId);
toast.success(t("toast.taskDeleted"));
setDeleteTargetId(null);
} catch {
toast.error(t("toast.failedDeleteTask"));
// toast handled by hook
} finally {
setSingleDeleting(false);
}
@@ -4,7 +4,6 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { toast } from "@/stores/use-toast-store";
import type { TeamTaskData, TeamMemberData, ScopeEntry } from "@/types/team";
interface CreateTaskDialogProps {
@@ -44,13 +43,12 @@ export function CreateTaskDialog({
channel: selectedScope?.channel || undefined,
chatId: selectedScope?.chat_id || undefined,
});
toast.success(t("toast.taskCreated"));
setSubject(""); setDescription(""); setPriority(0);
setTaskType("general"); setAssignTo("");
onOpenChange(false);
onCreated();
} catch {
toast.error(t("toast.failedCreateTask"));
// toast handled by hook
} finally {
setCreating(false);
}
@@ -8,7 +8,6 @@ import { Combobox } from "@/components/ui/combobox";
import { Bot, UserPlus, X, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { toast } from "@/stores/use-toast-store";
import type { TeamMemberData } from "@/types/team";
interface TeamMembersDialogProps {
@@ -77,9 +76,8 @@ export function TeamMembersDialog({
await onAddMember(selected, "member");
setSelected("");
setShowAdd(false);
toast.success(t("members.added"));
} catch {
toast.error(t("members.failedAdd"));
// toast handled by hook
} finally {
setAdding(false);
}
@@ -89,9 +87,8 @@ export function TeamMembersDialog({
setRemoving(agentId);
try {
await onRemoveMember(agentId);
toast.success(t("members.removed"));
} catch {
toast.error(t("members.failedRemove"));
// toast handled by hook
} finally {
setRemoving(null);
}
@@ -2,6 +2,9 @@ import { useState, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import type { TeamWorkspaceFile } from "@/types/team";
import { toast } from "@/stores/use-toast-store";
import i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
export function useTeamWorkspace() {
const ws = useWs();
@@ -40,11 +43,17 @@ export function useTeamWorkspace() {
const deleteFile = useCallback(
async (teamId: string, fileName: string, chatId?: string) => {
await ws.call(Methods.TEAMS_WORKSPACE_DELETE, {
team_id: teamId,
file_name: fileName,
chat_id: chatId ?? "",
});
try {
await ws.call(Methods.TEAMS_WORKSPACE_DELETE, {
team_id: teamId,
file_name: fileName,
chat_id: chatId ?? "",
});
toast.success(i18next.t("teams:toast.workspaceFileDeleted"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedDeleteWorkspaceFile"), userFriendlyError(err));
throw err;
}
},
[ws],
);
+87 -18
View File
@@ -3,6 +3,9 @@ import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import type { TeamData, TeamMemberData, TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment, TeamAccessSettings, ScopeEntry } from "@/types/team";
import { toast } from "@/stores/use-toast-store";
import i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
export function useTeams() {
const ws = useWs();
@@ -32,16 +35,28 @@ export function useTeams() {
members: string[];
description?: string;
}) => {
await ws.call(Methods.TEAMS_CREATE, params);
load();
try {
await ws.call(Methods.TEAMS_CREATE, params);
load();
toast.success(i18next.t("teams:toast.created"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedCreate"), userFriendlyError(err));
throw err;
}
},
[ws, load],
);
const deleteTeam = useCallback(
async (teamId: string) => {
await ws.call(Methods.TEAMS_DELETE, { teamId });
load();
try {
await ws.call(Methods.TEAMS_DELETE, { teamId });
load();
toast.success(i18next.t("teams:toast.deleted"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedDelete"), userFriendlyError(err));
throw err;
}
},
[ws, load],
);
@@ -105,14 +120,26 @@ export function useTeams() {
const approveTask = useCallback(
async (teamId: string, taskId: string, comment?: string) => {
await ws.call(Methods.TEAMS_TASK_APPROVE, { teamId, taskId, comment });
try {
await ws.call(Methods.TEAMS_TASK_APPROVE, { teamId, taskId, comment });
toast.success(i18next.t("teams:toast.taskApproved"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedApproveTask"), userFriendlyError(err));
throw err;
}
},
[ws],
);
const rejectTask = useCallback(
async (teamId: string, taskId: string, reason?: string) => {
await ws.call(Methods.TEAMS_TASK_REJECT, { teamId, taskId, reason });
try {
await ws.call(Methods.TEAMS_TASK_REJECT, { teamId, taskId, reason });
toast.success(i18next.t("teams:toast.taskRejected"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedRejectTask"), userFriendlyError(err));
throw err;
}
},
[ws],
);
@@ -148,54 +175,96 @@ export function useTeams() {
const createTask = useCallback(
async (teamId: string, params: { subject: string; description?: string; priority?: number; taskType?: string; assignTo?: string; channel?: string; chatId?: string }) => {
const res = await ws.call<{ task: TeamTaskData }>(
Methods.TEAMS_TASK_CREATE,
{ teamId, ...params },
);
return res.task;
try {
const res = await ws.call<{ task: TeamTaskData }>(
Methods.TEAMS_TASK_CREATE,
{ teamId, ...params },
);
toast.success(i18next.t("teams:toast.taskCreated"));
return res.task;
} catch (err) {
toast.error(i18next.t("teams:toast.failedCreateTask"), userFriendlyError(err));
throw err;
}
},
[ws],
);
const deleteTask = useCallback(
async (teamId: string, taskId: string) => {
await ws.call(Methods.TEAMS_TASK_DELETE, { teamId, taskId });
try {
await ws.call(Methods.TEAMS_TASK_DELETE, { teamId, taskId });
toast.success(i18next.t("teams:toast.taskDeleted"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedDeleteTask"), userFriendlyError(err));
throw err;
}
},
[ws],
);
const deleteTasksBulk = useCallback(
async (teamId: string, taskIds: string[]) => {
const res = await ws.call<{ deleted: number }>(Methods.TEAMS_TASK_DELETE_BULK, { teamId, taskIds });
return res.deleted;
try {
const res = await ws.call<{ deleted: number }>(Methods.TEAMS_TASK_DELETE_BULK, { teamId, taskIds });
toast.success(i18next.t("teams:toast.tasksBulkDeleted"));
return res.deleted;
} catch (err) {
toast.error(i18next.t("teams:toast.failedBulkDeleteTasks"), userFriendlyError(err));
throw err;
}
},
[ws],
);
const assignTask = useCallback(
async (teamId: string, taskId: string, agentId: string) => {
await ws.call(Methods.TEAMS_TASK_ASSIGN, { teamId, taskId, agentId });
try {
await ws.call(Methods.TEAMS_TASK_ASSIGN, { teamId, taskId, agentId });
toast.success(i18next.t("teams:toast.taskAssigned"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedAssignTask"), userFriendlyError(err));
throw err;
}
},
[ws],
);
const addMember = useCallback(
async (teamId: string, agent: string, role?: string) => {
await ws.call(Methods.TEAMS_MEMBERS_ADD, { teamId, agent, role });
try {
await ws.call(Methods.TEAMS_MEMBERS_ADD, { teamId, agent, role });
toast.success(i18next.t("teams:toast.memberAdded"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedAddMember"), userFriendlyError(err));
throw err;
}
},
[ws],
);
const removeMember = useCallback(
async (teamId: string, agentId: string) => {
await ws.call(Methods.TEAMS_MEMBERS_REMOVE, { teamId, agentId });
try {
await ws.call(Methods.TEAMS_MEMBERS_REMOVE, { teamId, agentId });
toast.success(i18next.t("teams:toast.memberRemoved"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedRemoveMember"), userFriendlyError(err));
throw err;
}
},
[ws],
);
const updateTeamSettings = useCallback(
async (teamId: string, settings: TeamAccessSettings) => {
await ws.call(Methods.TEAMS_UPDATE, { teamId, settings });
try {
await ws.call(Methods.TEAMS_UPDATE, { teamId, settings });
toast.success(i18next.t("teams:toast.updated"));
} catch (err) {
toast.error(i18next.t("teams:toast.failedUpdate"), userFriendlyError(err));
throw err;
}
},
[ws],
);
@@ -8,7 +8,6 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/lib/format";
import { toast } from "@/stores/use-toast-store";
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils";
@@ -59,10 +58,9 @@ export function TaskDetailDialog({
setDeleting(true);
try {
await deleteTask(teamId, task.id);
toast.success(t("toast.taskDeleted"));
onClose();
} catch {
toast.error(t("toast.failedDeleteTask"));
// toast handled by hook
} finally {
setDeleting(false);
setConfirmDelete(false);
+5 -26
View File
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Combobox } from "@/components/ui/combobox";
import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync, Zap, Bot, Loader2 } from "lucide-react";
import { X, Save, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync, Zap, Bot, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CHANNEL_TYPES } from "@/constants/channels";
import type { TeamData, TeamAccessSettings, EscalationMode, EscalationAction } from "@/types/team";
@@ -85,8 +85,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
const isTeamV2 = version >= 2;
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [versionModalOpen, setVersionModalOpen] = useState(false);
// Load known users for combobox
@@ -113,14 +111,10 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
setFollowupInterval(s.followup_interval_minutes ?? 30);
setFollowupMaxReminders(s.followup_max_reminders ?? 0);
setWorkspaceScope(s.workspace_scope ?? "isolated");
setSaved(false);
setError(null);
}, [team]);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
setSaved(false);
try {
const settings: TeamAccessSettings = {};
if (allowUserIds.length > 0) settings.allow_user_ids = allowUserIds;
@@ -143,15 +137,12 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
settings.workspace_scope = workspaceScope || "isolated";
if (version >= 2) settings.version = version;
await updateTeamSettings(teamId, settings);
setSaved(true);
onSaved();
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : t("settings.failedSave"));
} catch { // toast shown by hook
} finally {
setSaving(false);
}
}, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifySlowTool, notifyMode, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]);
}, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifySlowTool, notifyMode, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]);
const userOptions = knownUsers.map((u) => ({ value: u, label: u }));
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
@@ -458,21 +449,9 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
{/* Save button */}
<div className="flex items-center gap-3">
<Button onClick={handleSave} disabled={saving} className="gap-2">
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> {t("settings.saving")}
</>
) : saved ? (
<>
<Check className="h-4 w-4" /> {t("settings.saved")}
</>
) : (
<>
<Save className="h-4 w-4" /> {t("settings.save")}
</>
)}
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("settings.saving") : t("settings.save")}
</Button>
{error && <span className="text-sm text-destructive">{error}</span>}
</div>
<TeamVersionModal open={versionModalOpen} onOpenChange={setVersionModalOpen} />
@@ -4,6 +4,9 @@ import { useWs } 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 i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
export interface TtsProviderConfig {
api_key?: string;
@@ -71,9 +74,11 @@ export function useTtsConfig() {
try {
await ws.call(Methods.CONFIG_PATCH, { raw: JSON.stringify({ tts: updates }) });
await invalidate();
toast.success(i18next.t("config:toast.saved"));
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save TTS config";
setError(msg);
toast.error(i18next.t("config:toast.saveFailed"), userFriendlyError(err));
throw err;
} finally {
setSaving(false);