- {isBusy ? (
+ {voiceRecorder.isRecording ? (
+ <>
+
+
+ >
+ ) : isBusy ? (
<>
+ }
+ />
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {hooks.map((hook) => (
+ setEditTarget(hook)}
+ onToggle={(enabled) => toggleMutation.mutate({ hookId: hook.id, enabled })}
+ onEdit={() => setEditTarget(hook)}
+ onDelete={() => setDeleteTarget(hook)}
+ onTest={() => setTestTarget(hook)}
+ />
+ ))}
+
+
+
+
+ {editTarget && (
+
{ if (!o) setEditTarget(null); }}
+ onSubmit={handleUpdate}
+ initial={editTarget}
+ />
+ )}
+
+ {deleteTarget && (
+ setDeleteTarget(null)}
+ title={th("actions.delete")}
+ description={t("hooks.tab.deleteConfirm", { event: deleteTarget.event })}
+ confirmLabel={th("actions.delete")}
+ variant="destructive"
+ onConfirm={async () => {
+ await deleteMutation.mutateAsync(deleteTarget.id);
+ setDeleteTarget(null);
+ }}
+ />
+ )}
+
+ {testTarget && (
+
+ )}
+ >
+ );
+}
diff --git a/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx
index 84937029..5e68ebcd 100644
--- a/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx
+++ b/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx
@@ -14,6 +14,7 @@ import { OrchestrationSection } from "./overview-sections/orchestration-section"
import { CapabilitiesSection } from "./overview-sections/capabilities-section";
import { ChatGPTOAuthRoutingSummarySection } from "./overview-sections/chatgpt-oauth-routing-summary-section";
import { HeartbeatCard } from "./overview-sections/heartbeat-card";
+import { HooksSummaryCard } from "./overview-sections/hooks-summary-card";
import { MemorySection } from "./config-sections";
import type { UseAgentHeartbeatReturn } from "../hooks/use-agent-heartbeat";
@@ -22,9 +23,11 @@ interface AgentOverviewTabProps {
onUpdate: (updates: Record) => Promise;
heartbeat: UseAgentHeartbeatReturn;
onManageCodexPool: () => void;
+ onViewHooks: () => void;
+ onAddHook: () => void;
}
-export function AgentOverviewTab({ agent, onUpdate, heartbeat, onManageCodexPool }: AgentOverviewTabProps) {
+export function AgentOverviewTab({ agent, onUpdate, heartbeat, onManageCodexPool, onViewHooks, onAddHook }: AgentOverviewTabProps) {
const { t } = useTranslation("agents");
// Personality
@@ -161,6 +164,12 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat, onManageCodexPool
+
+
diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/hooks-summary-card.tsx b/ui/web/src/pages/agents/agent-detail/overview-sections/hooks-summary-card.tsx
new file mode 100644
index 00000000..6c235b1c
--- /dev/null
+++ b/ui/web/src/pages/agents/agent-detail/overview-sections/hooks-summary-card.tsx
@@ -0,0 +1,126 @@
+import { useState, useMemo } from "react";
+import { Webhook, ChevronDown, ChevronUp, Plus } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { useHooksList } from "@/hooks/use-hooks";
+
+interface HooksSummaryCardProps {
+ agentId: string;
+ onViewAll: () => void;
+ onAddHook: () => void;
+}
+
+const EVENT_COLORS: Record = {
+ session_start: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
+ user_prompt_submit: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
+ pre_tool_use: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
+ post_tool_use: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
+ stop: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
+ subagent_start: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
+ subagent_stop: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
+};
+
+export function HooksSummaryCard({ agentId, onViewAll, onAddHook }: HooksSummaryCardProps) {
+ const { t } = useTranslation("agents");
+ const [expanded, setExpanded] = useState(false);
+ const { data: hooks = [], isPending } = useHooksList({ agentId, scope: "agent" });
+
+ const groupedByEvent = useMemo(() => {
+ const groups: Record = {};
+ hooks.forEach((h) => {
+ groups[h.event] = (groups[h.event] || 0) + 1;
+ });
+ return Object.entries(groups)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5);
+ }, [hooks]);
+
+ const totalCount = hooks.length;
+
+ // Loading state
+ if (isPending) {
+ return (
+
+
+
+ {t("hooks.card.loading")}
+
+
+ );
+ }
+
+ // Empty state
+ if (totalCount === 0) {
+ return (
+
+
+
+
+ {t("hooks.card.title")}
+
+
+
{t("hooks.card.empty")}
+
{t("hooks.card.emptyDesc")}
+
+
+ );
+ }
+
+ // Has hooks - collapsible
+ return (
+
+
+
+ {expanded && (
+ <>
+
+ {groupedByEvent.map(([event, count]) => (
+
+
+ {event}
+
+
+ {t("hooks.card.nHooks", { count })}
+
+
+ ))}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/ui/web/src/pages/tts/sections/credentials-section.tsx b/ui/web/src/pages/tts/sections/credentials-section.tsx
index 9bdd8151..6f02f04f 100644
--- a/ui/web/src/pages/tts/sections/credentials-section.tsx
+++ b/ui/web/src/pages/tts/sections/credentials-section.tsx
@@ -41,9 +41,11 @@ export function CredentialsSection({ provider, draft, onUpdate, testConnection,
try {
// Build params from draft credentials — test with unsaved config
const cfg = draft[provider as keyof Pick];
+ // Don't send masked API key — backend will reject it
+ const apiKey = cfg?.api_key === "***" ? undefined : cfg?.api_key;
const params: TestConnectionParams = {
provider,
- api_key: cfg?.api_key,
+ api_key: apiKey,
api_base: cfg?.api_base || cfg?.base_url,
voice_id: cfg?.voice_id || cfg?.voice,
model_id: cfg?.model_id || cfg?.model,