feat(ui): refactor agent detail page — 3 tabs, heartbeat UI, compact header

- Reduce 5 tabs to 3 (Agent, Files, Instances) + Advanced Settings dialog
- Team-style compact header with heartbeat countdown, advanced, delete buttons
- New heartbeat UI: config dialog with channel/chatId picker, logs viewer
- Personality section with large emoji + frontmatter textarea
- Model & Budget merged section, inline skills toggles
- Capabilities section with collapsible Memory/Tools/Subagents
- Auto-refresh heartbeat when countdown reaches 0 (poll every 5s)
- Shared useCountdown hook for mm:ss / hh:mm:ss display
This commit is contained in:
viettranx committed 2026-03-18 16:37:53 +07:00
1 parent 96cfd1bf08
commit 1ae5c7641a
27 files changed
+2261 -1003

No files matched your search

+12
View File
@@ -152,6 +152,16 @@ export const Methods = {
TEAMS_WORKSPACE_READ: "teams.workspace.read",
TEAMS_WORKSPACE_DELETE: "teams.workspace.delete",
// Heartbeat
HEARTBEAT_GET: "heartbeat.get",
HEARTBEAT_SET: "heartbeat.set",
HEARTBEAT_TOGGLE: "heartbeat.toggle",
HEARTBEAT_TEST: "heartbeat.test",
HEARTBEAT_LOGS: "heartbeat.logs",
HEARTBEAT_CHECKLIST_GET: "heartbeat.checklist.get",
HEARTBEAT_CHECKLIST_SET: "heartbeat.checklist.set",
HEARTBEAT_TARGETS: "heartbeat.targets",
// Phase 3+ - NICE TO HAVE
LOGS_TAIL: "logs.tail",
} as const;
@@ -218,6 +228,8 @@ export const Events = {
// Skill dependency install (triggered by POST /v1/skills/install-deps)
SKILL_DEPS_INSTALLING: "skill.deps.installing",
SKILL_DEPS_INSTALLED: "skill.deps.installed",
HEARTBEAT: "heartbeat",
} as const;
/** All event names relevant to team debug view */
@@ -7,6 +7,7 @@ interface StickySaveBarProps {
saving: boolean;
saved: boolean;
error?: string | null;
disabled?: boolean;
label?: string;
savingLabel?: string;
savedLabel?: string;
@@ -18,6 +19,7 @@ export function StickySaveBar({
saving,
saved,
error,
disabled,
label,
savingLabel,
savedLabel,
@@ -40,7 +42,7 @@ export function StickySaveBar({
<Check className="h-3.5 w-3.5" /> {resolvedSavedLabel}
</span>
)}
<Button onClick={onSave} disabled={saving}>
<Button onClick={onSave} disabled={saving || disabled}>
{!saving && <Save className="h-4 w-4" />}
{saving ? resolvedSavingLabel : resolvedLabel}
</Button>
+33
View File
@@ -0,0 +1,33 @@
import { useState, useEffect } from "react";
/** Format seconds into mm:ss or hh:mm:ss countdown. */
export function formatCountdown(seconds: number): string {
if (seconds <= 0) return "00:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const mm = String(m).padStart(2, "0");
const ss = String(s).padStart(2, "0");
if (h > 0) return `${String(h).padStart(2, "0")}:${mm}:${ss}`;
return `${mm}:${ss}`;
}
/**
* Hook that ticks a countdown from a target ISO date string.
* Returns formatted string like "12m", "1h 5m", "now", or null if no target.
* Ticks every `tickMs` (default 1s for mm:ss display).
*/
export function useCountdown(targetISO: string | undefined | null, tickMs = 1_000): string | null {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!targetISO) return;
setNow(Date.now()); // immediate sync on target change
const id = setInterval(() => setNow(Date.now()), tickMs);
return () => clearInterval(id);
}, [targetISO, tickMs]);
if (!targetISO) return null;
const diff = Math.max(0, Math.floor((new Date(targetISO).getTime() - now) / 1000));
return formatCountdown(diff);
}
+69 -1
View File
@@ -81,6 +81,7 @@
},
"detail": {
"tabs": {
"agent": "Agent",
"general": "General",
"config": "Config",
"files": "Files",
@@ -93,7 +94,14 @@
"evolving": "Evolving",
"static": "Static",
"evolvingTooltipDetail": "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed.",
"staticTooltipDetail": "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation."
"staticTooltipDetail": "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation.",
"advanced": "Advanced",
"personality": "Agent Personality",
"modelBudget": "Model & Budget",
"skills": "Skills",
"evolution": "Evolution",
"capabilities": "Capabilities",
"llmSeesAs": "LLM sees this as"
},
"general": {
"selfEvolution": "Self-Evolution",
@@ -398,6 +406,66 @@
"invalidJson": "Invalid JSON syntax"
}
},
"heartbeat": {
"title": "Heartbeat",
"loading": "Loading heartbeat...",
"notConfigured": "No heartbeat schedule configured. Set one up to run this agent on autopilot.",
"setupButton": "Setup Heartbeat",
"toggleEnabled": "Toggle heartbeat enabled",
"every": "Every {{interval}}m",
"lastRun": "Last",
"nextRun": "Next",
"runs": "Runs",
"suppressed": "Suppressed",
"configure": "Configure",
"logs": "Logs",
"configTitle": "Heartbeat Configuration",
"enabled": "Enabled",
"enabledHint": "When enabled, the agent runs on the configured interval.",
"sectionTiming": "Timing",
"interval": "Interval (seconds)",
"intervalHint": "Minimum 300 seconds (5 minutes).",
"maxRetries": "Max Retries",
"maxRetriesHint": "0–10 retries on failure.",
"ackMaxChars": "Ack Max Chars",
"ackMaxCharsHint": "Max characters in the acknowledgement reply. 0 = no reply.",
"sectionBehavior": "Behavior",
"isolatedSession": "Isolated Session",
"isolatedSessionHint": "Run in a fresh session, not continuing the last conversation.",
"lightContext": "Light Context",
"lightContextHint": "Skip loading full context files to reduce token usage.",
"sectionSchedule": "Schedule",
"activeHoursStart": "Active From (HH:MM)",
"activeHoursEnd": "Active Until (HH:MM)",
"timezone": "Timezone",
"sectionDelivery": "Delivery",
"channel": "Channel",
"channelPlaceholder": "Select channel",
"channelNone": "None (no delivery)",
"channelHint": "e.g. telegram, feishu. Leave empty for no delivery.",
"chatId": "Chat ID",
"chatIdPlaceholder": "Select chat",
"selectChannelFirst": "Select a channel first",
"checklist": "Checklist (HEARTBEAT.md)",
"checklistHint": "Instructions injected into each heartbeat run. Supports markdown.",
"checklistLoading": "Loading checklist...",
"checklistPlaceholder": "# Heartbeat Checklist\n\n- Check pending tasks\n- Report status\n",
"on": "On",
"off": "Off",
"notSet": "Not Set",
"disabled": "Heartbeat disabled",
"nextIn": "Next in {{time}}",
"advancedSettings": "Advanced Settings",
"scheduleHint": "Leave empty for 24/7 operation.",
"testRun": "Test Run",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"logsTitle": "Heartbeat Logs",
"refresh": "Refresh",
"noLogs": "No heartbeat logs yet.",
"logsPagination": "{{from}}–{{to}} of {{total}}"
},
"toast": {
"created": "Agent created",
"createFailed": "Failed to create agent",
+69 -1
View File
@@ -81,6 +81,7 @@
},
"detail": {
"tabs": {
"agent": "Agent",
"general": "Chung",
"config": "Cấu hình",
"files": "Tệp",
@@ -93,7 +94,14 @@
"evolving": "Đang tiến hóa",
"static": "Tĩnh",
"evolvingTooltipDetail": "Agent có thể tự tiến hóa phong cách giao tiếp (SOUL.md). Nhận dạng và quy trình làm việc giữ nguyên.",
"staticTooltipDetail": "Phong cách agent là tĩnh. Bật Tự Tiến Hóa trong tab Chung để cho phép thích nghi phong cách."
"staticTooltipDetail": "Phong cách agent là tĩnh. Bật Tự Tiến Hóa trong tab Chung để cho phép thích nghi phong cách.",
"advanced": "Nâng cao",
"personality": "Cá tính Agent",
"modelBudget": "Mô hình & Ngân sách",
"skills": "Kỹ năng",
"evolution": "Tiến hóa",
"capabilities": "Khả năng",
"llmSeesAs": "LLM nhìn thấy đây là"
},
"general": {
"selfEvolution": "Tự tiến hóa",
@@ -398,6 +406,66 @@
"invalidJson": "Cú pháp JSON không hợp lệ"
}
},
"heartbeat": {
"title": "Heartbeat",
"loading": "Đang tải heartbeat...",
"notConfigured": "Chưa cấu hình lịch heartbeat. Thiết lập để agent chạy tự động.",
"setupButton": "Thiết lập Heartbeat",
"toggleEnabled": "Bật/tắt heartbeat",
"every": "Mỗi {{interval}} phút",
"lastRun": "Lần cuối",
"nextRun": "Lần tới",
"runs": "Lần chạy",
"suppressed": "Bị bỏ qua",
"configure": "Cấu hình",
"logs": "Nhật ký",
"configTitle": "Cấu hình Heartbeat",
"enabled": "Kích hoạt",
"enabledHint": "Khi bật, agent sẽ chạy theo chu kỳ đã cấu hình.",
"sectionTiming": "Thời gian",
"interval": "Chu kỳ (giây)",
"intervalHint": "Tối thiểu 300 giây (5 phút).",
"maxRetries": "Số lần thử lại",
"maxRetriesHint": "0–10 lần thử lại khi thất bại.",
"ackMaxChars": "Ký tự phản hồi tối đa",
"ackMaxCharsHint": "Số ký tự tối đa trong phản hồi. 0 = không phản hồi.",
"sectionBehavior": "Hành vi",
"isolatedSession": "Phiên cô lập",
"isolatedSessionHint": "Chạy trong phiên mới, không tiếp tục cuộc trò chuyện trước.",
"lightContext": "Ngữ cảnh nhẹ",
"lightContextHint": "Bỏ qua tải đầy đủ tệp ngữ cảnh để giảm token.",
"sectionSchedule": "Lịch hoạt động",
"activeHoursStart": "Bắt đầu (HH:MM)",
"activeHoursEnd": "Kết thúc (HH:MM)",
"timezone": "Múi giờ",
"sectionDelivery": "Gửi kết quả",
"channel": "Kênh",
"channelPlaceholder": "Chọn kênh",
"channelNone": "Không (không gửi)",
"channelHint": "Ví dụ: telegram, feishu. Để trống nếu không gửi.",
"chatId": "Chat ID",
"chatIdPlaceholder": "Chọn chat",
"selectChannelFirst": "Chọn kênh trước",
"checklist": "Danh sách kiểm tra (HEARTBEAT.md)",
"checklistHint": "Hướng dẫn được đưa vào mỗi lần chạy heartbeat. Hỗ trợ markdown.",
"checklistLoading": "Đang tải danh sách kiểm tra...",
"checklistPlaceholder": "# Danh sách kiểm tra Heartbeat\n\n- Kiểm tra các tác vụ đang chờ\n- Báo cáo trạng thái\n",
"on": "Bật",
"off": "Tắt",
"notSet": "Chưa cài",
"disabled": "Heartbeat đã tắt",
"nextIn": "Còn {{time}}",
"advancedSettings": "Cài đặt nâng cao",
"scheduleHint": "Để trống để chạy 24/7.",
"testRun": "Chạy thử",
"cancel": "Hủy",
"save": "Lưu",
"saving": "Đang lưu...",
"logsTitle": "Nhật ký Heartbeat",
"refresh": "Làm mới",
"noLogs": "Chưa có nhật ký heartbeat.",
"logsPagination": "{{from}}–{{to}} / {{total}}"
},
"toast": {
"created": "Đã tạo agent",
"createFailed": "Không thể tạo agent",
+69 -1
View File
@@ -81,6 +81,7 @@
},
"detail": {
"tabs": {
"agent": "Agent",
"general": "基本信息",
"config": "配置",
"files": "文件",
@@ -93,7 +94,14 @@
"evolving": "进化中",
"static": "静态",
"evolvingTooltipDetail": "Agent可以自我进化其沟通风格 (SOUL.md)。身份和工作流程保持不变。",
"staticTooltipDetail": "Agent风格是静态的。在常规选项卡中启用自我进化以允许风格适应。"
"staticTooltipDetail": "Agent风格是静态的。在常规选项卡中启用自我进化以允许风格适应。",
"advanced": "高级",
"personality": "Agent个性",
"modelBudget": "模型与预算",
"skills": "技能",
"evolution": "进化",
"capabilities": "能力",
"llmSeesAs": "LLM会看到这个为"
},
"general": {
"selfEvolution": "自我进化",
@@ -398,6 +406,66 @@
"invalidJson": "JSON 语法无效"
}
},
"heartbeat": {
"title": "心跳",
"loading": "加载心跳配置中...",
"notConfigured": "尚未配置心跳计划。设置后可让代理自动运行。",
"setupButton": "设置心跳",
"toggleEnabled": "启用/禁用心跳",
"every": "每 {{interval}} 分钟",
"lastRun": "上次",
"nextRun": "下次",
"runs": "运行次数",
"suppressed": "已抑制",
"configure": "配置",
"logs": "日志",
"configTitle": "心跳配置",
"enabled": "启用",
"enabledHint": "启用后,代理将按配置的周期运行。",
"sectionTiming": "时间设置",
"interval": "间隔(秒)",
"intervalHint": "最短 300 秒(5 分钟)。",
"maxRetries": "最大重试次数",
"maxRetriesHint": "失败时重试 0–10 次。",
"ackMaxChars": "响应最大字符数",
"ackMaxCharsHint": "响应的最大字符数。0 = 不响应。",
"sectionBehavior": "行为",
"isolatedSession": "独立会话",
"isolatedSessionHint": "在全新会话中运行,不延续上次对话。",
"lightContext": "轻量上下文",
"lightContextHint": "跳过加载完整上下文文件以减少令牌消耗。",
"sectionSchedule": "活跃时段",
"activeHoursStart": "开始时间(HH:MM)",
"activeHoursEnd": "结束时间(HH:MM)",
"timezone": "时区",
"sectionDelivery": "推送设置",
"channel": "渠道",
"channelPlaceholder": "选择渠道",
"channelNone": "无(不推送)",
"channelHint": "例如 telegram、feishu。留空则不推送。",
"chatId": "Chat ID",
"chatIdPlaceholder": "选择会话",
"selectChannelFirst": "请先选择渠道",
"checklist": "检查清单(HEARTBEAT.md)",
"checklistHint": "每次心跳运行时注入的指令,支持 Markdown。",
"checklistLoading": "加载检查清单中...",
"checklistPlaceholder": "# 心跳检查清单\n\n- 检查待处理任务\n- 报告状态\n",
"on": "开启",
"off": "关闭",
"notSet": "未设置",
"disabled": "心跳已关闭",
"nextIn": "{{time}}后",
"advancedSettings": "高级设置",
"scheduleHint": "留空则24/7全天运行。",
"testRun": "测试运行",
"cancel": "取消",
"save": "保存",
"saving": "保存中...",
"logsTitle": "心跳日志",
"refresh": "刷新",
"noLogs": "暂无心跳日志。",
"logsPagination": "{{from}}–{{to}} / 共 {{total}}"
},
"toast": {
"created": "代理已创建",
"createFailed": "创建代理失败",
@@ -0,0 +1,163 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import type {
AgentData, CompactionConfig, ContextPruningConfig,
SandboxConfig, WorkspaceSharingConfig,
} from "@/types/agent";
import {
ThinkingSection, WorkspaceSharingSection, CompactionSection,
ContextPruningSection, SandboxSection,
} from "./config-sections";
import { WorkspaceSection } from "./general-sections";
interface AgentAdvancedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: AgentData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: AgentAdvancedDialogProps) {
const { t } = useTranslation("agents");
const deriveState = (a: AgentData) => {
const otherObj = (a.other_config ?? {}) as Record<string, unknown>;
return {
thinkingLevel: typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off",
wsSharing: (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig,
comp: a.compaction_config ?? {},
pruneEnabled: a.context_pruning != null,
prune: a.context_pruning ?? {},
sbEnabled: a.sandbox_config != null,
sb: a.sandbox_config ?? {},
};
};
const init = deriveState(agent);
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(init.wsSharing);
const [thinkingLevel, setThinkingLevel] = useState(init.thinkingLevel);
const [comp, setComp] = useState<CompactionConfig>(init.comp);
const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled);
const [prune, setPrune] = useState<ContextPruningConfig>(init.prune);
const [sbEnabled, setSbEnabled] = useState(init.sbEnabled);
const [sb, setSb] = useState<SandboxConfig>(init.sb);
// Re-sync local state when dialog opens (picks up latest agent data from React Query)
useEffect(() => {
if (!open) return;
const s = deriveState(agent);
setThinkingLevel(s.thinkingLevel);
setWsSharing(s.wsSharing);
setComp(s.comp);
setPruneEnabled(s.pruneEnabled);
setPrune(s.prune);
setSbEnabled(s.sbEnabled);
setSb(s.sb);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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
// the latest agent data and merge our keys into it.
const existing = (agent.other_config as Record<string, unknown> | null) ?? {};
const otherBase: Record<string, unknown> = { ...existing };
delete otherBase.thinking_level;
delete otherBase.workspace_sharing;
if (thinkingLevel && thinkingLevel !== "off") {
otherBase.thinking_level = thinkingLevel;
}
if (
wsSharing.shared_dm || wsSharing.shared_group ||
(wsSharing.shared_users?.length ?? 0) > 0 || wsSharing.share_memory
) {
otherBase.workspace_sharing = wsSharing;
}
await onUpdate({
compaction_config: comp,
context_pruning: pruneEnabled ? prune : null,
sandbox_config: sbEnabled ? sb : null,
other_config: otherBase,
});
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("config.failedToSave"));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
{t("detail.advanced")}
</DialogTitle>
</DialogHeader>
{/* Scrollable body */}
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
{/* Workspace (read-only) */}
<WorkspaceSection workspace={agent.workspace} />
{/* Workspace Sharing */}
<WorkspaceSharingSection value={wsSharing} onChange={setWsSharing} />
{/* Thinking */}
<ThinkingSection value={thinkingLevel} onChange={setThinkingLevel} />
{/* Performance */}
<ConfigGroupHeader
title={t("configGroups.performance")}
description={t("configGroups.performanceDesc")}
/>
<div className="space-y-4">
<CompactionSection value={comp} onChange={setComp} />
<ContextPruningSection
enabled={pruneEnabled}
value={prune}
onToggle={(v) => { setPruneEnabled(v); if (!v) setPrune({}); }}
onChange={setPrune}
/>
<SandboxSection
enabled={sbEnabled}
value={sb}
onToggle={(v) => { setSbEnabled(v); if (!v) setSb({}); }}
onChange={setSb}
/>
</div>
</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>
</DialogContent>
</Dialog>
);
}
@@ -1,177 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import type {
AgentData,
SubagentsConfig,
ToolPolicyConfig,
CompactionConfig,
ContextPruningConfig,
SandboxConfig,
MemoryConfig,
WorkspaceSharingConfig,
} from "@/types/agent";
import {
SubagentsSection,
ToolPolicySection,
CompactionSection,
ContextPruningSection,
SandboxSection,
MemorySection,
ThinkingSection,
WorkspaceSharingSection,
} from "./config-sections";
interface AgentConfigTabProps {
agent: AgentData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
const { t } = useTranslation("agents");
const [subEnabled, setSubEnabled] = useState(agent.subagents_config != null);
const [sub, setSub] = useState<SubagentsConfig>(agent.subagents_config ?? {});
const [toolsEnabled, setToolsEnabled] = useState(agent.tools_config != null);
const [tools, setTools] = useState<ToolPolicyConfig>(agent.tools_config ?? {});
const [comp, setComp] = useState<CompactionConfig>(agent.compaction_config ?? {});
const [pruneEnabled, setPruneEnabled] = useState(agent.context_pruning != null);
const [prune, setPrune] = useState<ContextPruningConfig>(agent.context_pruning ?? {});
const [sbEnabled, setSbEnabled] = useState(agent.sandbox_config != null);
const [sb, setSb] = useState<SandboxConfig>(agent.sandbox_config ?? {});
const [memEnabled, setMemEnabled] = useState(agent.memory_config != null);
const [mem, setMem] = useState<MemoryConfig>(agent.memory_config ?? {});
const otherObj = (agent.other_config ?? {}) as Record<string, unknown>;
const initialThinkingLevel = (typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off");
const initialWsSharing = (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig;
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(initialWsSharing);
const [thinkingLevel, setThinkingLevel] = useState(initialThinkingLevel);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
const updates: Record<string, unknown> = {
subagents_config: subEnabled ? sub : null,
tools_config: toolsEnabled
? { profile: tools.profile, allow: tools.allow, deny: tools.deny, alsoAllow: tools.alsoAllow, byProvider: tools.byProvider }
: {},
compaction_config: comp,
context_pruning: pruneEnabled ? prune : null,
sandbox_config: sbEnabled ? sb : null,
memory_config: memEnabled ? mem : null,
};
// Preserve existing other_config fields not managed by this tab (e.g. emoji from General tab).
const existing = (agent.other_config as Record<string, unknown> | null) ?? {};
const otherBase: Record<string, unknown> = { ...existing };
// Strip fields managed by this tab — they'll be re-added below from local state.
delete otherBase.thinking_level;
delete otherBase.workspace_sharing;
if (thinkingLevel && thinkingLevel !== "off") {
otherBase.thinking_level = thinkingLevel;
}
if (wsSharing.shared_dm || wsSharing.shared_group || (wsSharing.shared_users?.length ?? 0) > 0 || wsSharing.share_memory) {
otherBase.workspace_sharing = wsSharing;
}
updates.other_config = Object.keys(otherBase).length > 0 ? otherBase : {};
await onUpdate(updates);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("config.failedToSave"));
} finally {
setSaving(false);
}
};
return (
<div className="max-w-4xl space-y-4">
{/* Workspace & Security */}
<WorkspaceSharingSection
value={wsSharing}
onChange={setWsSharing}
/>
{/* Core */}
<ThinkingSection
value={thinkingLevel}
onChange={setThinkingLevel}
/>
{/* Capabilities */}
<ConfigGroupHeader
title={t("configGroups.capabilities")}
description={t("configGroups.capabilitiesDesc")}
/>
<div className="space-y-4">
<MemorySection
enabled={memEnabled}
value={mem}
onToggle={(v: boolean) => { setMemEnabled(v); if (!v) setMem({}); }}
onChange={setMem}
/>
<SubagentsSection
enabled={subEnabled}
value={sub}
onToggle={(v: boolean) => { setSubEnabled(v); if (!v) setSub({}); }}
onChange={setSub}
/>
<ToolPolicySection
enabled={toolsEnabled}
value={tools}
onToggle={(v: boolean) => { setToolsEnabled(v); if (!v) setTools({}); }}
onChange={setTools}
/>
</div>
{/* Performance */}
<ConfigGroupHeader
title={t("configGroups.performance")}
description={t("configGroups.performanceDesc")}
/>
<div className="space-y-4">
<CompactionSection
value={comp}
onChange={setComp}
/>
<ContextPruningSection
enabled={pruneEnabled}
value={prune}
onToggle={(v: boolean) => { setPruneEnabled(v); if (!v) setPrune({}); }}
onChange={setPrune}
/>
<SandboxSection
enabled={sbEnabled}
value={sb}
onToggle={(v: boolean) => { setSbEnabled(v); if (!v) setSb({}); }}
onChange={setSb}
/>
</div>
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
label={t("config.saveConfig")}
savingLabel={t("config.saving")}
savedLabel={t("config.saved")}
/>
</div>
);
}
@@ -1,60 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import type { AgentData } from "@/types/agent";
interface AgentDangerTabProps {
agent: AgentData;
onDelete: () => Promise<void>;
onDeleted: () => void;
}
export function AgentDangerTab({ agent, onDelete, onDeleted }: AgentDangerTabProps) {
const { t } = useTranslation("agents");
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<div className="space-y-6">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-destructive">{t("delete.title")}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t("delete.description")}
</p>
</div>
<Button
variant="destructive"
className="shrink-0 gap-2"
disabled={agent.is_default}
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="h-4 w-4" />
{t("delete.title")}
</Button>
</div>
{agent.is_default && (
<p className="mt-3 text-xs text-muted-foreground">
{t("delete.defaultCannotDelete")}
</p>
)}
</div>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t("delete.title")}
description={t("delete.detailDescription", { name: agent.display_name || agent.agent_key })}
confirmLabel={t("delete.confirmLabel")}
variant="destructive"
onConfirm={async () => {
await onDelete();
setConfirmOpen(false);
onDeleted();
}}
/>
</div>
);
}
@@ -1,48 +1,36 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ArrowLeft, Bot, Star, Trash2, Sparkles } from "lucide-react";
import { useAgentDetail } from "../hooks/use-agent-detail";
import { useAgents } from "../hooks/use-agents";
import { AgentGeneralTab } from "./agent-general-tab";
import { AgentConfigTab } from "./agent-config-tab";
import { useAgentHeartbeat } from "../hooks/use-agent-heartbeat";
import { AgentHeader } from "./agent-header";
import { AgentOverviewTab } from "./agent-overview-tab";
import { AgentFilesTab } from "./agent-files-tab";
import { AgentSkillsTab } from "./agent-skills-tab";
import { AgentInstancesTab } from "./agent-instances-tab";
import { AgentAdvancedDialog } from "./agent-advanced-dialog";
import { HeartbeatConfigDialog } from "./heartbeat-config-dialog";
import { SummoningModal } from "../summoning-modal";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
import { agentDisplayName } from "./agent-display-utils";
interface AgentDetailPageProps {
agentId: string;
onBack: () => void;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function agentDisplayName(agent: { display_name?: string; agent_key: string }, unnamedLabel: string) {
if (agent.display_name) return agent.display_name;
if (UUID_RE.test(agent.agent_key)) return unnamedLabel;
return agent.agent_key;
}
function agentSubtitle(agent: { display_name?: string; agent_key: string; id: string }) {
if (!agent.display_name && agent.agent_key === agent.id) return null;
if (UUID_RE.test(agent.agent_key)) return agent.agent_key.slice(0, 8) + "…";
return agent.agent_key;
}
export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
const { t } = useTranslation("agents");
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh } =
useAgentDetail(agentId);
const { deleteAgent: deleteAgentById } = useAgents();
const hb = useAgentHeartbeat(agentId);
const [summoningOpen, setSummoningOpen] = useState(false);
const [activeTab, setActiveTab] = useState("general");
const [activeTab, setActiveTab] = useState("agent");
const [deleteOpen, setDeleteOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [heartbeatOpen, setHeartbeatOpen] = useState(false);
const handleRegenerate = async (prompt: string) => {
await regenerateAgent(prompt);
@@ -60,137 +48,65 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
};
if (loading || !agent) {
return <DetailPageSkeleton tabs={6} />;
return <DetailPageSkeleton tabs={3} />;
}
const title = agentDisplayName(agent, t("card.unnamedAgent"));
const subtitle = agentSubtitle(agent);
const emoji = typeof (agent.other_config as Record<string, unknown> | null)?.emoji === "string"
? (agent.other_config as Record<string, unknown>).emoji as string
: "";
return (
<TooltipProvider>
<div className="p-4 sm:p-6">
{/* Header */}
<div className="mb-6 flex items-start gap-4">
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
{emoji ? <span className="text-2xl leading-none">{emoji}</span> : <Bot className="h-6 w-6" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-xl font-semibold">{title}</h2>
{agent.is_default && (
<Star className="h-4 w-4 shrink-0 fill-amber-400 text-amber-400" />
)}
<Badge variant={agent.status === "active" ? "success" : agent.status === "summon_failed" ? "destructive" : "secondary"}>
{agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{subtitle && (
<>
<span className="font-mono text-xs">{subtitle}</span>
<span className="text-border">|</span>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
{agent.agent_type === "predefined"
? t("card.predefinedTooltip")
: t("card.openTooltip")}
</TooltipContent>
</Tooltip>
{agent.agent_type === "predefined" && (() => {
const evolving = Boolean((agent.other_config as Record<string, unknown> | null)?.self_evolve);
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={evolving ? "default" : "outline"}
className={`text-[11px] ${evolving ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`}
>
<Sparkles className="mr-0.5 h-3 w-3" />
{evolving ? t("detail.evolving") : t("detail.static")}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px] text-xs">
{evolving
? t("detail.evolvingTooltipDetail")
: t("detail.staticTooltipDetail")}
</TooltipContent>
</Tooltip>
);
})()}
{agent.provider && (
<>
<span className="text-border">|</span>
<span>{agent.provider} / {agent.model}</span>
</>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div>
<AgentHeader
agent={agent}
heartbeat={hb.config}
onBack={onBack}
onDelete={() => setDeleteOpen(true)}
onAdvanced={() => setAdvancedOpen(true)}
onHeartbeat={() => setHeartbeatOpen(true)}
/>
{/* Tabs */}
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="general">{t("detail.tabs.general")}</TabsTrigger>
<TabsTrigger value="config">{t("detail.tabs.config")}</TabsTrigger>
<TabsTrigger value="files">{t("detail.tabs.files")}</TabsTrigger>
<TabsTrigger value="skills">{t("detail.tabs.skills")}</TabsTrigger>
{agent.agent_type === "predefined" && (
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
)}
</TabsList>
<div className="p-3 sm:p-4">
<div className="max-w-4xl">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="agent">{t("detail.tabs.agent")}</TabsTrigger>
<TabsTrigger value="files">{t("detail.tabs.files")}</TabsTrigger>
{agent.agent_type === "predefined" && (
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
)}
</TabsList>
<TabsContent value="general" className="mt-4">
<AgentGeneralTab agent={agent} onUpdate={updateAgent} />
</TabsContent>
<TabsContent value="config" className="mt-4">
<AgentConfigTab agent={agent} onUpdate={updateAgent} />
</TabsContent>
<TabsContent value="files" className="mt-4">
<AgentFilesTab
agent={agent}
files={files}
onGetFile={getFile}
onSetFile={setFile}
onRegenerate={handleRegenerate}
onResummon={handleResummon}
/>
</TabsContent>
<TabsContent value="skills" className="mt-4">
<AgentSkillsTab agentId={agentId} />
</TabsContent>
{agent.agent_type === "predefined" && (
<TabsContent value="instances" className="mt-4">
<AgentInstancesTab agentId={agentId} />
<TabsContent value="agent" className="mt-4">
<AgentOverviewTab key={agent.id + "-" + agent.updated_at} agent={agent} onUpdate={updateAgent} heartbeat={hb} />
</TabsContent>
)}
</Tabs>
<TabsContent value="files" className="mt-4">
<AgentFilesTab
agent={agent}
files={files}
onGetFile={getFile}
onSetFile={setFile}
onRegenerate={handleRegenerate}
onResummon={handleResummon}
/>
</TabsContent>
{agent.agent_type === "predefined" && (
<TabsContent value="instances" className="mt-4">
<AgentInstancesTab agentId={agentId} />
</TabsContent>
)}
</Tabs>
</div>
</div>
<AgentAdvancedDialog
key={agent.id}
open={advancedOpen}
onOpenChange={setAdvancedOpen}
agent={agent}
onUpdate={updateAgent}
/>
<SummoningModal
open={summoningOpen}
onOpenChange={handleSummoningClose}
@@ -200,6 +116,21 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
onResummon={async () => { await resummonAgent(); }}
/>
{heartbeatOpen && (
<HeartbeatConfigDialog
open={heartbeatOpen}
onOpenChange={setHeartbeatOpen}
config={hb.config}
saving={hb.saving}
update={hb.update}
test={hb.test}
getChecklist={hb.getChecklist}
setChecklist={hb.setChecklist}
fetchTargets={hb.fetchTargets}
refresh={hb.refresh}
/>
)}
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
@@ -214,6 +145,5 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
}}
/>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,17 @@
/** Matches a standard UUID v4 string. */
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Returns the display name for an agent, falling back to agent_key or unnamedLabel. */
export function agentDisplayName(
agent: { display_name?: string; agent_key: string },
unnamedLabel: string,
): string {
if (agent.display_name) return agent.display_name;
if (UUID_RE.test(agent.agent_key)) return unnamedLabel;
return agent.agent_key;
}
/** Returns a shortened agent key for subtitle display (truncates UUIDs). */
export function agentKeyDisplay(agentKey: string): string {
return UUID_RE.test(agentKey) ? agentKey.slice(0, 8) + "…" : agentKey;
}
@@ -1,260 +0,0 @@
import { useState, useCallback } from "react";
import { Save, Check, AlertCircle, Sparkles, Info, DollarSign, GraduationCap } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import type { AgentData } from "@/types/agent";
import { IdentitySection, LlmConfigSection, WorkspaceSection } from "./general-sections";
interface AgentGeneralTabProps {
agent: AgentData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
const { t } = useTranslation("agents");
// Identity
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
const [frontmatter, setFrontmatter] = useState(agent.frontmatter ?? "");
const [status, setStatus] = useState(agent.status);
const [isDefault, setIsDefault] = useState(agent.is_default);
// LLM
const [provider, setProvider] = useState(agent.provider);
const [model, setModel] = useState(agent.model);
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
const [llmSaveBlocked, setLlmSaveBlocked] = useState(false);
// Budget (stored in cents, displayed in dollars)
const [budgetDollars, setBudgetDollars] = useState(
agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "",
);
// Self-evolve & skill-evolve (predefined agents only)
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
const [emoji, setEmoji] = useState(typeof otherCfg.emoji === "string" ? otherCfg.emoji : "");
const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve));
const [skillEvolve, setSkillEvolve] = useState(Boolean(otherCfg.skill_evolve));
const [skillNudgeInterval, setSkillNudgeInterval] = useState(
typeof otherCfg.skill_nudge_interval === "number" ? otherCfg.skill_nudge_interval : 15,
);
// Save state
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const handleSaveBlockedChange = useCallback((blocked: boolean) => {
setLlmSaveBlocked(blocked);
}, []);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
const updatedOtherConfig = {
...otherCfg,
self_evolve: selfEvolve,
skill_evolve: skillEvolve,
skill_nudge_interval: skillEvolve ? skillNudgeInterval : undefined,
emoji: emoji.trim() || undefined,
};
const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null;
await onUpdate({
display_name: displayName,
frontmatter: frontmatter || null,
provider,
model,
context_window: contextWindow,
max_tool_iterations: maxToolIterations,
status,
is_default: isDefault,
other_config: updatedOtherConfig,
budget_monthly_cents: budgetCents,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("general.failedToSave"));
} finally {
setSaving(false);
}
};
return (
<div className="max-w-4xl space-y-6">
<IdentitySection
agentKey={agent.agent_key}
emoji={emoji}
onEmojiChange={setEmoji}
displayName={displayName}
onDisplayNameChange={setDisplayName}
frontmatter={frontmatter}
onFrontmatterChange={setFrontmatter}
status={status}
onStatusChange={setStatus}
isDefault={isDefault}
onIsDefaultChange={setIsDefault}
/>
{/* Self-Evolve (predefined agents only) — right after Identity */}
{agent.agent_type === "predefined" && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center gap-3">
<Sparkles className="h-4 w-4 text-violet-500" />
<h3 className="text-sm font-medium">{t("general.selfEvolution")}</h3>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="self-evolve" className="text-sm font-normal">
{t("general.selfEvolutionLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("general.selfEvolutionHint")}
</p>
</div>
<Switch
id="self-evolve"
checked={selfEvolve}
onCheckedChange={setSelfEvolve}
/>
</div>
{selfEvolve && (
<div className="flex items-start gap-2 rounded-md border border-violet-200 bg-violet-50 px-3 py-2 text-xs text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("general.selfEvolutionInfo")}</span>
</div>
)}
</div>
</>
)}
{/* Skill Learning (predefined agents only) — right after Self-Evolve */}
{agent.agent_type === "predefined" && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center gap-3">
<GraduationCap className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-medium">{t("general.skillLearning")}</h3>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="skill-evolve" className="text-sm font-normal">
{t("general.skillLearningLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("general.skillLearningHint")}
</p>
</div>
<Switch
id="skill-evolve"
checked={skillEvolve}
onCheckedChange={setSkillEvolve}
/>
</div>
{skillEvolve && (
<>
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("general.skillLearningInfo")}</span>
</div>
<div className="space-y-1">
<Label htmlFor="nudge-interval" className="text-xs font-normal text-muted-foreground">
{t("general.skillNudgeIntervalLabel")}
</Label>
<Input
id="nudge-interval"
type="number"
min={0}
max={100}
value={skillNudgeInterval}
onChange={(e) => setSkillNudgeInterval(Number(e.target.value) || 0)}
className="max-w-[120px] text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">
{t("general.skillNudgeIntervalHint")}
</p>
</div>
</>
)}
</div>
</>
)}
<Separator />
<LlmConfigSection
provider={provider}
onProviderChange={setProvider}
model={model}
onModelChange={setModel}
contextWindow={contextWindow}
onContextWindowChange={setContextWindow}
maxToolIterations={maxToolIterations}
onMaxToolIterationsChange={setMaxToolIterations}
savedProvider={agent.provider}
savedModel={agent.model}
onSaveBlockedChange={handleSaveBlockedChange}
/>
<Separator />
<WorkspaceSection workspace={agent.workspace} />
{/* Budget */}
<Separator />
<div className="space-y-3">
<div className="flex items-center gap-3">
<DollarSign className="h-4 w-4 text-emerald-500" />
<h3 className="text-sm font-medium">{t("general.budget")}</h3>
</div>
<div className="space-y-1">
<Label htmlFor="budget" className="text-sm font-normal">
{t("general.budgetLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("general.budgetHint")}
</p>
</div>
<Input
id="budget"
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={budgetDollars}
onChange={(e) => setBudgetDollars(e.target.value)}
className="max-w-[200px]"
/>
</div>
{/* Save */}
{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("general.saved")}
</span>
)}
<Button onClick={handleSave} disabled={saving || llmSaveBlocked}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("general.saving") : t("general.saveChanges")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ArrowLeft, Bot, Heart, Settings, Sparkles, Star, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AgentData } from "@/types/agent";
import type { HeartbeatConfig } from "@/pages/agents/hooks/use-agent-heartbeat";
import { useCountdown } from "@/hooks/use-countdown";
import { agentDisplayName, agentKeyDisplay } from "./agent-display-utils";
interface AgentHeaderProps {
agent: AgentData;
heartbeat: HeartbeatConfig | null;
onBack: () => void;
onDelete: () => void;
onAdvanced: () => void;
onHeartbeat: () => void;
}
export function AgentHeader({ agent, heartbeat, onBack, onDelete, onAdvanced, onHeartbeat }: AgentHeaderProps) {
const { t } = useTranslation("agents");
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
const emoji = typeof otherCfg.emoji === "string" ? otherCfg.emoji : "";
const selfEvolve = Boolean(otherCfg.self_evolve);
const title = agentDisplayName(agent, t("card.unnamedAgent"));
const keyDisplay = agentKeyDisplay(agent.agent_key);
const hbConfigured = heartbeat != null;
const hbEnabled = heartbeat?.enabled ?? false;
const countdown = useCountdown(hbEnabled ? heartbeat?.nextRunAt : null);
return (
<TooltipProvider>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-card px-3 py-2 landscape-compact sm:px-4 sm:gap-3">
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 size-9">
<ArrowLeft className="h-4 w-4" />
</Button>
{/* Emoji avatar */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:h-12 sm:w-12">
{emoji
? <span className="text-xl leading-none sm:text-2xl">{emoji}</span>
: <Bot className="h-5 w-5 sm:h-6 sm:w-6" />}
</div>
{/* Agent info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<h2 className="truncate text-base font-semibold">{title}</h2>
{agent.is_default && (
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
)}
<Badge
variant={
agent.status === "active"
? "success"
: agent.status === "summon_failed"
? "destructive"
: "secondary"
}
className="text-[10px]"
>
{agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-[10px]">{agent.agent_type}</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
{agent.agent_type === "predefined" ? t("card.predefinedTooltip") : t("card.openTooltip")}
</TooltipContent>
</Tooltip>
{agent.agent_type === "predefined" && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={selfEvolve ? "default" : "outline"}
className={`text-[10px] ${selfEvolve ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`}
>
<Sparkles className="mr-0.5 h-2.5 w-2.5" />
{selfEvolve ? t("detail.evolving") : t("detail.static")}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px] text-xs">
{selfEvolve ? t("detail.evolvingTooltipDetail") : t("detail.staticTooltipDetail")}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span className="font-mono text-[11px]">{keyDisplay}</span>
{agent.provider && (
<>
<span className="text-border">·</span>
<span>{agent.provider} / {agent.model}</span>
</>
)}
</div>
</div>
{/* Heartbeat action */}
<Button variant="ghost" size="sm" onClick={onHeartbeat} className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3">
<Heart className={`h-4 w-4 ${hbEnabled ? "fill-rose-500 text-rose-500 animate-pulse" : hbConfigured ? "text-rose-400" : "text-muted-foreground"}`} />
<span className={`hidden sm:inline ${hbEnabled ? "text-rose-600 dark:text-rose-400" : ""}`}>
{!hbConfigured
? t("heartbeat.notSet")
: !hbEnabled
? t("heartbeat.off")
: countdown
? t("heartbeat.nextIn", { time: countdown })
: t("heartbeat.on")}
</span>
</Button>
<Button variant="ghost" size="sm" onClick={onAdvanced} className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3">
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.advanced")}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
</Button>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,175 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type {
AgentData, MemoryConfig, SubagentsConfig, ToolPolicyConfig,
} from "@/types/agent";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { PersonalitySection } from "./overview-sections/personality-section";
import { ModelBudgetSection } from "./overview-sections/model-budget-section";
import { SkillsSection } from "./overview-sections/skills-section";
import { EvolutionSection } from "./overview-sections/evolution-section";
import { CapabilitiesSection } from "./overview-sections/capabilities-section";
import { HeartbeatCard } from "./overview-sections/heartbeat-card";
import type { UseAgentHeartbeatReturn } from "../hooks/use-agent-heartbeat";
interface AgentOverviewTabProps {
agent: AgentData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
heartbeat: UseAgentHeartbeatReturn;
}
export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTabProps) {
const { t } = useTranslation("agents");
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
// Personality
const [emoji, setEmoji] = useState(typeof otherCfg.emoji === "string" ? otherCfg.emoji : "");
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
const [frontmatter, setFrontmatter] = useState(agent.frontmatter ?? "");
const [status, setStatus] = useState(agent.status);
const [isDefault, setIsDefault] = useState(agent.is_default);
// Model & Budget
const [provider, setProvider] = useState(agent.provider);
const [model, setModel] = useState(agent.model);
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
const [budgetDollars, setBudgetDollars] = useState(
agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "",
);
// Evolution (predefined only)
const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve));
const [skillEvolve, setSkillEvolve] = useState(Boolean(otherCfg.skill_evolve));
const [skillNudgeInterval, setSkillNudgeInterval] = useState(
typeof otherCfg.skill_nudge_interval === "number" ? otherCfg.skill_nudge_interval : 15,
);
// Capabilities
const [memEnabled, setMemEnabled] = useState(agent.memory_config != null);
const [mem, setMem] = useState<MemoryConfig>(agent.memory_config ?? {});
const [subEnabled, setSubEnabled] = useState(agent.subagents_config != null);
const [sub, setSub] = useState<SubagentsConfig>(agent.subagents_config ?? {});
const [toolsEnabled, setToolsEnabled] = useState(agent.tools_config != null);
const [tools, setTools] = useState<ToolPolicyConfig>(agent.tools_config ?? {});
// 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,
emoji: emoji.trim() || undefined,
self_evolve: selfEvolve,
skill_evolve: skillEvolve,
skill_nudge_interval: skillEvolve ? skillNudgeInterval : undefined,
};
const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null;
await onUpdate({
display_name: displayName,
frontmatter: frontmatter || null,
provider,
model,
context_window: contextWindow,
max_tool_iterations: maxToolIterations,
status,
is_default: isDefault,
other_config: updatedOtherConfig,
budget_monthly_cents: budgetCents,
memory_config: memEnabled ? mem : null,
subagents_config: subEnabled ? sub : null,
tools_config: toolsEnabled
? { 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"));
} finally {
setSaving(false);
}
};
return (
<div className="space-y-4">
<PersonalitySection
agentKey={agent.agent_key}
emoji={emoji}
onEmojiChange={setEmoji}
displayName={displayName}
onDisplayNameChange={setDisplayName}
frontmatter={frontmatter}
onFrontmatterChange={setFrontmatter}
status={status}
onStatusChange={setStatus}
isDefault={isDefault}
onIsDefaultChange={setIsDefault}
/>
<ModelBudgetSection
provider={provider}
onProviderChange={setProvider}
model={model}
onModelChange={setModel}
contextWindow={contextWindow}
onContextWindowChange={setContextWindow}
maxToolIterations={maxToolIterations}
onMaxToolIterationsChange={setMaxToolIterations}
savedProvider={agent.provider}
savedModel={agent.model}
budgetDollars={budgetDollars}
onBudgetDollarsChange={setBudgetDollars}
onSaveBlockedChange={setLlmSaveBlocked}
/>
<HeartbeatCard heartbeat={heartbeat} />
<SkillsSection agentId={agent.id} />
{agent.agent_type === "predefined" && (
<EvolutionSection
selfEvolve={selfEvolve}
onSelfEvolveChange={setSelfEvolve}
skillEvolve={skillEvolve}
onSkillEvolveChange={setSkillEvolve}
skillNudgeInterval={skillNudgeInterval}
onSkillNudgeIntervalChange={setSkillNudgeInterval}
/>
)}
<CapabilitiesSection
memEnabled={memEnabled}
mem={mem}
onMemToggle={setMemEnabled}
onMemChange={setMem}
subEnabled={subEnabled}
sub={sub}
onSubToggle={setSubEnabled}
onSubChange={setSub}
toolsEnabled={toolsEnabled}
tools={tools}
onToolsToggle={setToolsEnabled}
onToolsChange={setTools}
/>
<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,129 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { Zap } from "lucide-react";
import { useAgentSkills } from "../hooks/use-agent-skills";
interface AgentSkillsTabProps {
agentId: string;
}
const visibilityVariant = (v: string) => {
switch (v) {
case "public":
return "success";
case "internal":
return "secondary";
case "private":
return "outline";
default:
return "outline";
}
};
export function AgentSkillsTab({ agentId }: AgentSkillsTabProps) {
const { t } = useTranslation("agents");
const { skills, loading, grantSkill, revokeSkill } = useAgentSkills(agentId);
const [search, setSearch] = useState("");
const [toggling, setToggling] = useState<string | null>(null);
const filtered = skills
.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.slug.toLowerCase().includes(search.toLowerCase()) ||
s.description.toLowerCase().includes(search.toLowerCase()),
)
.sort((a, b) => {
// Priority: ungranted non-system (0) → system (1) → granted (2)
const rank = (s: typeof a) => s.is_system ? 1 : s.granted ? 2 : 0;
return rank(a) - rank(b);
});
const handleToggle = async (skillId: string, granted: boolean) => {
setToggling(skillId);
try {
if (granted) {
await revokeSkill(skillId);
} else {
await grantSkill(skillId);
}
} finally {
setToggling(null);
}
};
if (loading && skills.length === 0) {
return <TableSkeleton />;
}
if (!loading && skills.length === 0) {
return (
<EmptyState
icon={Zap}
title={t("skills.noSkillsAvailable")}
description={t("skills.noSkillsDesc")}
/>
);
}
return (
<div className="max-w-4xl space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{t("skills.skillsGranted", {
granted: skills.filter((s) => s.granted).length,
total: skills.length,
})}
</p>
<SearchInput
value={search}
onChange={setSearch}
placeholder={t("skills.filterSkills")}
className="w-full sm:w-64"
/>
</div>
<div className="divide-y rounded-lg border">
{filtered.map((skill) => (
<div key={skill.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{skill.name}</span>
<Badge variant={visibilityVariant(skill.visibility)} className="text-[10px]">
{skill.visibility}
</Badge>
{skill.is_system && (
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px]">
{t("skills.system")}
</Badge>
)}
</div>
{skill.description && (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{skill.description}</p>
)}
</div>
{skill.is_system ? (
<span className="text-xs text-muted-foreground whitespace-nowrap">{t("skills.alwaysAvailable")}</span>
) : (
<Switch
checked={skill.granted}
disabled={toggling === skill.id}
onCheckedChange={() => handleToggle(skill.id, skill.granted)}
/>
)}
</div>
))}
{filtered.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t("skills.noSkillsMatch")}
</div>
)}
</div>
</div>
);
}
@@ -1,151 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/** Extract the first emoji (single grapheme cluster) from a string, or empty. */
function extractSingleEmoji(str: string): string {
// Match first emoji (handles compound emoji like flags, skin tones)
const match = str.match(/\p{Emoji_Presentation}(\u200D\p{Emoji_Presentation})*/u)
?? str.match(/\p{Extended_Pictographic}(\uFE0F?\u200D\p{Extended_Pictographic})*/u);
return match?.[0] ?? "";
}
interface IdentitySectionProps {
agentKey: string;
emoji?: string;
onEmojiChange?: (v: string) => void;
displayName: string;
onDisplayNameChange: (v: string) => void;
frontmatter: string;
onFrontmatterChange: (v: string) => void;
status: string;
onStatusChange: (v: string) => void;
isDefault: boolean;
onIsDefaultChange: (v: boolean) => void;
}
export function IdentitySection({
agentKey,
emoji,
onEmojiChange,
displayName,
onDisplayNameChange,
frontmatter,
onFrontmatterChange,
status,
onStatusChange,
isDefault,
onIsDefaultChange,
}: IdentitySectionProps) {
const { t } = useTranslation("agents");
const [copied, setCopied] = useState(false);
const copyAgentKey = async () => {
await navigator.clipboard.writeText(agentKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">{t("identity.title")}</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<Label>{t("identity.agentKey")}</Label>
<div className="flex items-center gap-2">
<Input value={agentKey} disabled className="font-mono text-sm" />
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={copyAgentKey}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("identity.agentKeyHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">{t("identity.displayName")}</Label>
<div className="flex gap-2">
<Input
id="emoji"
value={emoji ?? ""}
onChange={(e) => {
const picked = extractSingleEmoji(e.target.value);
onEmojiChange?.(picked);
}}
onFocus={(e) => e.target.select()}
placeholder="🤖"
className="w-14 shrink-0 text-center text-base md:text-lg"
title={t("identity.emojiHint")}
/>
<Input
id="displayName"
value={displayName}
onChange={(e) => onDisplayNameChange(e.target.value)}
placeholder={t("identity.displayNamePlaceholder")}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("identity.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="frontmatter">{t("identity.expertiseSummary")}</Label>
<Input
id="frontmatter"
value={frontmatter}
onChange={(e) => onFrontmatterChange(e.target.value)}
placeholder={t("identity.expertiseSummaryPlaceholder")}
/>
<p className="text-xs text-muted-foreground">
{t("identity.expertiseSummaryHint")}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("identity.status")}</Label>
<Select value={status} onValueChange={onStatusChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t("identity.active")}</SelectItem>
<SelectItem value="inactive">{t("identity.inactive")}</SelectItem>
{status === "summon_failed" && (
<SelectItem value="summon_failed" disabled>
{t("identity.summonFailed")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-2">
<Switch checked={isDefault} onCheckedChange={onIsDefaultChange} />
<Label>{t("identity.defaultAgent")}</Label>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -1,3 +1 @@
export { IdentitySection } from "./identity-section";
export { LlmConfigSection } from "./llm-config-section";
export { WorkspaceSection } from "./workspace-section";
@@ -1,77 +0,0 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
interface LlmConfigSectionProps {
provider: string;
onProviderChange: (v: string) => void;
model: string;
onModelChange: (v: string) => void;
contextWindow: number;
onContextWindowChange: (v: number) => void;
maxToolIterations: number;
onMaxToolIterationsChange: (v: number) => void;
savedProvider: string;
savedModel: string;
/** Called when verification status changes. True = save should be blocked. */
onSaveBlockedChange?: (blocked: boolean) => void;
}
export function LlmConfigSection({
provider,
onProviderChange,
model,
onModelChange,
contextWindow,
onContextWindowChange,
maxToolIterations,
onMaxToolIterationsChange,
savedProvider,
savedModel,
onSaveBlockedChange,
}: LlmConfigSectionProps) {
const { t } = useTranslation("agents");
return (
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">{t("llmConfig.title")}</h3>
<div className="space-y-4 rounded-lg border p-4">
<ProviderModelSelect
provider={provider}
onProviderChange={onProviderChange}
model={model}
onModelChange={onModelChange}
savedProvider={savedProvider}
savedModel={savedModel}
onSaveBlockedChange={onSaveBlockedChange}
providerTip="LLM provider name. Must match a configured provider."
modelTip="Model ID to use."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="contextWindow">{t("llmConfig.contextWindow")}</Label>
<Input
id="contextWindow"
type="number"
value={contextWindow || ""}
onChange={(e) => onContextWindowChange(Number(e.target.value) || 0)}
placeholder="200000"
/>
<p className="text-xs text-muted-foreground">{t("llmConfig.contextWindowHint")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="maxToolIterations">{t("llmConfig.maxToolIterations")}</Label>
<Input
id="maxToolIterations"
type="number"
value={maxToolIterations || ""}
onChange={(e) => onMaxToolIterationsChange(Number(e.target.value) || 0)}
placeholder="25"
/>
<p className="text-xs text-muted-foreground">{t("llmConfig.maxToolIterationsHint")}</p>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,404 @@
import { useState, useEffect, useCallback } from "react";
import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useChannels } from "@/pages/channels/hooks/use-channels";
import { useUiStore } from "@/stores/use-ui-store";
import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
interface HeartbeatConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: HeartbeatConfig | null;
saving: boolean;
update: (params: Partial<HeartbeatConfig>) => Promise<void>;
test: () => Promise<void>;
getChecklist: () => Promise<string>;
setChecklist: (content: string) => Promise<void>;
fetchTargets: () => Promise<DeliveryTarget[]>;
refresh: () => Promise<void>;
}
export function HeartbeatConfigDialog({
open, onOpenChange, config, saving, update, test, getChecklist, setChecklist, fetchTargets, refresh,
}: HeartbeatConfigDialogProps) {
const { t } = useTranslation("agents");
const { channels: availableChannels } = useChannels();
const channelNames = Object.keys(availableChannels);
const userTz = useUiStore((s) => s.timezone);
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const defaultTz = userTz && userTz !== "auto" ? userTz : browserTz;
// Form state
const [enabled, setEnabled] = useState(false);
const [intervalMin, setIntervalMin] = useState(30);
const [ackMaxChars, setAckMaxChars] = useState(300);
const [maxRetries, setMaxRetries] = useState(2);
const [isolatedSession, setIsolatedSession] = useState(true);
const [lightContext, setLightContext] = useState(false);
const [activeHoursStart, setActiveHoursStart] = useState("");
const [activeHoursEnd, setActiveHoursEnd] = useState("");
const [timezone, setTimezone] = useState("");
const [channel, setChannel] = useState("");
const [chatId, setChatId] = useState("");
const [checklist, setChecklistState] = useState("");
const [originalChecklist, setOriginalChecklist] = useState("");
const [checklistLoading, setChecklistLoading] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
const [testRunning, setTestRunning] = useState(false);
const showTestSpin = useMinLoading(testRunning, 600);
const loadChecklist = useCallback(async () => {
setChecklistLoading(true);
try {
const content = await getChecklist();
setChecklistState(content);
setOriginalChecklist(content);
} catch { /* ignore */ } finally {
setChecklistLoading(false);
}
}, [getChecklist]);
// Sync form state only when dialog opens (false→true).
useEffect(() => {
if (!open) return;
if (config) {
setEnabled(config.enabled);
setIntervalMin(Math.round(config.intervalSec / 60));
setAckMaxChars(config.ackMaxChars);
setMaxRetries(config.maxRetries);
setIsolatedSession(config.isolatedSession);
setLightContext(config.lightContext);
setActiveHoursStart(config.activeHoursStart ?? "");
setActiveHoursEnd(config.activeHoursEnd ?? "");
setTimezone(config.timezone || defaultTz);
setChannel(config.channel ?? "");
setChatId(config.chatId ?? "");
} else {
// First-time setup defaults
setTimezone(defaultTz);
}
setShowAdvanced(false);
loadChecklist();
fetchTargets().then(setTargets).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleTest = async () => {
setTestRunning(true);
try { await test(); } finally { setTestRunning(false); }
};
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,
});
if (checklist !== originalChecklist) {
await setChecklist(checklist);
setOriginalChecklist(checklist);
}
await refresh();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Heart className="h-4 w-4 text-rose-500" />
{t("heartbeat.configTitle")}
<Badge variant={enabled ? "success" : "secondary"} className="text-[10px]">
{enabled ? t("heartbeat.on") : t("heartbeat.off")}
</Badge>
</DialogTitle>
</DialogHeader>
{/* Scrollable body — standard pattern: -mx-4 px-4 */}
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4 overscroll-contain">
{/* ── Enable + Interval (top priority) ── */}
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="flex items-center gap-3 min-w-0">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<div className="min-w-0">
<span className="text-sm font-medium">{t("heartbeat.enabled")}</span>
<p className="text-xs text-muted-foreground">{t("heartbeat.enabledHint")}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<Input
type="number"
min={5}
value={intervalMin}
onChange={(e) => setIntervalMin(Math.max(5, Number(e.target.value) || 5))}
className="w-16 text-center text-base md:text-sm"
/>
<span className="text-xs text-muted-foreground">min</span>
</div>
</div>
{/* ── Delivery — WHERE it sends ── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Send className="h-3.5 w-3.5 text-blue-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.sectionDelivery")}
</h4>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">{t("heartbeat.channel")}</Label>
{channelNames.length > 0 ? (
<Select
value={channel || "__none__"}
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setChatId(""); }}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("heartbeat.channelPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
{channelNames.map((ch) => (
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder="telegram"
value={channel}
onChange={(e) => setChannel(e.target.value)}
className="text-base md:text-sm"
/>
)}
</div>
<div className="space-y-1">
<Label className="text-xs">{t("heartbeat.chatId")}</Label>
{(() => {
if (!channel) {
return (
<Input
placeholder={t("heartbeat.selectChannelFirst")}
disabled
className="text-base md:text-sm"
/>
);
}
const filtered = targets.filter((t) => t.channel === channel);
if (filtered.length > 0) {
return (
<Select
value={chatId || "__none__"}
onValueChange={(v) => setChatId(v === "__none__" ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("heartbeat.chatIdPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
{filtered.map((tgt) => (
<SelectItem key={tgt.chatId} value={tgt.chatId}>
{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
placeholder="-100123456789"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
className="text-base md:text-sm"
/>
);
})()}
</div>
</div>
</div>
{/* ── Schedule — WHEN it runs ── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-amber-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.sectionSchedule")}
</h4>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label htmlFor="hb-start" className="text-xs">{t("heartbeat.activeHoursStart")}</Label>
<Input
id="hb-start"
placeholder="08:00"
value={activeHoursStart}
onChange={(e) => setActiveHoursStart(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="hb-end" className="text-xs">{t("heartbeat.activeHoursEnd")}</Label>
<Input
id="hb-end"
placeholder="22:00"
value={activeHoursEnd}
onChange={(e) => setActiveHoursEnd(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="hb-tz" className="text-xs">{t("heartbeat.timezone")}</Label>
<Input
id="hb-tz"
placeholder="Asia/Ho_Chi_Minh"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="text-base md:text-sm"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t("heartbeat.scheduleHint")}</p>
</div>
{/* ── Checklist — WHAT the agent does ── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-emerald-500" />
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("heartbeat.checklist")}
</h4>
</div>
<p className="text-xs text-muted-foreground">{t("heartbeat.checklistHint")}</p>
{checklistLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-4 justify-center">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("heartbeat.checklistLoading")}
</div>
) : (
<Textarea
value={checklist}
onChange={(e) => setChecklistState(e.target.value)}
placeholder={t("heartbeat.checklistPlaceholder")}
rows={10}
className="text-base md:text-sm font-mono resize-y"
/>
)}
</div>
{/* ── Advanced (collapsible) ── */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t("heartbeat.advancedSettings")}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</button>
{showAdvanced && (
<div className="border-t px-3 py-3 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="hb-ack" className="text-xs">{t("heartbeat.ackMaxChars")}</Label>
<Input
id="hb-ack"
type="number"
min={0}
value={ackMaxChars}
onChange={(e) => setAckMaxChars(Number(e.target.value))}
className="text-base md:text-sm"
/>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.ackMaxCharsHint")}</p>
</div>
<div className="space-y-1">
<Label htmlFor="hb-retries" className="text-xs">{t("heartbeat.maxRetries")}</Label>
<Input
id="hb-retries"
type="number"
min={0}
max={10}
value={maxRetries}
onChange={(e) => setMaxRetries(Number(e.target.value))}
className="text-base md:text-sm"
/>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.maxRetriesHint")}</p>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium">{t("heartbeat.isolatedSession")}</span>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.isolatedSessionHint")}</p>
</div>
<Switch checked={isolatedSession} onCheckedChange={setIsolatedSession} />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium">{t("heartbeat.lightContext")}</span>
<p className="text-[11px] text-muted-foreground">{t("heartbeat.lightContextHint")}</p>
</div>
<Switch checked={lightContext} onCheckedChange={setLightContext} />
</div>
</div>
)}
</div>
{/* Bottom padding for scroll */}
<div className="h-1" />
</div>
{/* Footer — fixed at bottom */}
<div className="flex items-center justify-between gap-2 border-t pt-3">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={showTestSpin || saving}
className="gap-1.5"
>
{showTestSpin
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Play className="h-3.5 w-3.5" />}
{t("heartbeat.testRun")}
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t("heartbeat.cancel")}
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{saving ? t("heartbeat.saving") : t("heartbeat.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,165 @@
import { useState, useEffect, useCallback } from "react";
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useMinLoading } from "@/hooks/use-min-loading";
import type { HeartbeatLog } from "@/pages/agents/hooks/use-agent-heartbeat";
import { formatRelativeTime, formatDate } from "@/lib/format";
const PAGE_SIZE = 20;
type LogStatus = "ok" | "error" | "suppressed" | "skipped";
function statusVariant(status: string): "success" | "destructive" | "secondary" | "outline" {
switch (status as LogStatus) {
case "ok": return "success";
case "error": return "destructive";
case "suppressed": return "secondary";
default: return "outline";
}
}
interface HeartbeatLogsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fetchLogs: (limit: number, offset: number) => Promise<{ logs: HeartbeatLog[]; total: number }>;
}
export function HeartbeatLogsDialog({
open, onOpenChange, fetchLogs,
}: HeartbeatLogsDialogProps) {
const { t } = useTranslation("agents");
const [logs, setLogs] = useState<HeartbeatLog[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [fetching, setFetching] = useState(false);
const showSpin = useMinLoading(fetching, 600);
const load = useCallback(
async (newOffset: number) => {
setFetching(true);
try {
const res = await fetchLogs(PAGE_SIZE, newOffset);
setLogs(res.logs);
setTotal(res.total);
setOffset(newOffset);
} catch {
// toast handled inside hook if needed
} finally {
setFetching(false);
}
},
[fetchLogs],
);
useEffect(() => {
if (open) {
setOffset(0);
load(0);
}
}, [open, load]);
const hasPrev = offset > 0;
const hasNext = offset + PAGE_SIZE < total;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] sm:max-w-3xl flex flex-col gap-0 p-0">
<DialogHeader className="px-4 pt-4 pb-3 sm:px-6 sm:pt-6 flex-row items-center justify-between gap-2">
<DialogTitle>{t("heartbeat.logsTitle")}</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={() => load(offset)}
disabled={showSpin}
className="shrink-0"
>
<RefreshCw className={`h-3.5 w-3.5 ${showSpin ? "animate-spin" : ""}`} />
{t("heartbeat.refresh")}
</Button>
</DialogHeader>
<div className="overflow-y-auto min-h-0 max-h-[60vh] -mx-0 px-4 sm:px-6 overscroll-contain">
{fetching && logs.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : logs.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
{t("heartbeat.noLogs")}
</p>
) : (
<div className="space-y-2 py-1">
{logs.map((log) => (
<div key={log.id} className="rounded-md border p-3 text-sm space-y-1">
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="text-xs text-muted-foreground" title={formatDate(log.ran_at)}>
{formatRelativeTime(log.ran_at)}
</span>
<div className="flex items-center gap-2">
{log.duration_ms != null && (
<span className="text-xs text-muted-foreground">
{log.duration_ms}ms
</span>
)}
<Badge variant={statusVariant(log.status)} className="text-[10px]">
{log.status}
</Badge>
</div>
</div>
{log.summary && (
<p className="text-xs text-muted-foreground line-clamp-2">{log.summary}</p>
)}
{log.skip_reason && (
<p className="text-xs text-muted-foreground italic">{log.skip_reason}</p>
)}
{log.error && (
<p className="text-xs text-destructive">{log.error}</p>
)}
</div>
))}
</div>
)}
</div>
{/* Pagination */}
<div className="flex items-center justify-between gap-2 px-4 py-3 sm:px-6 border-t text-xs text-muted-foreground">
<span>
{total > 0
? t("heartbeat.logsPagination", {
from: offset + 1,
to: Math.min(offset + PAGE_SIZE, total),
total,
})
: ""}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => load(offset - PAGE_SIZE)}
disabled={!hasPrev || fetching}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => load(offset + PAGE_SIZE)}
disabled={!hasNext || fetching}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import type { MemoryConfig, SubagentsConfig, ToolPolicyConfig } from "@/types/agent";
import { MemorySection, SubagentsSection, ToolPolicySection } from "../config-sections";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
interface CapabilitiesSectionProps {
memEnabled: boolean;
mem: MemoryConfig;
onMemToggle: (v: boolean) => void;
onMemChange: (v: MemoryConfig) => void;
subEnabled: boolean;
sub: SubagentsConfig;
onSubToggle: (v: boolean) => void;
onSubChange: (v: SubagentsConfig) => void;
toolsEnabled: boolean;
tools: ToolPolicyConfig;
onToolsToggle: (v: boolean) => void;
onToolsChange: (v: ToolPolicyConfig) => void;
}
export function CapabilitiesSection({
memEnabled, mem, onMemToggle, onMemChange,
subEnabled, sub, onSubToggle, onSubChange,
toolsEnabled, tools, onToolsToggle, onToolsChange,
}: CapabilitiesSectionProps) {
const { t } = useTranslation("agents");
return (
<section className="space-y-4">
<ConfigGroupHeader
title={t("detail.capabilities")}
description={t("configGroups.capabilitiesDesc")}
/>
<div className="space-y-4">
<MemorySection
enabled={memEnabled}
value={mem}
onToggle={(v) => { onMemToggle(v); if (!v) onMemChange({}); }}
onChange={onMemChange}
/>
<SubagentsSection
enabled={subEnabled}
value={sub}
onToggle={(v) => { onSubToggle(v); if (!v) onSubChange({}); }}
onChange={onSubChange}
/>
<ToolPolicySection
enabled={toolsEnabled}
value={tools}
onToggle={(v) => { onToolsToggle(v); if (!v) onToolsChange({}); }}
onChange={onToolsChange}
/>
</div>
</section>
);
}
@@ -0,0 +1,89 @@
import { useTranslation } from "react-i18next";
import { GraduationCap, Info, Sparkles } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface EvolutionSectionProps {
selfEvolve: boolean;
onSelfEvolveChange: (v: boolean) => void;
skillEvolve: boolean;
onSkillEvolveChange: (v: boolean) => void;
skillNudgeInterval: number;
onSkillNudgeIntervalChange: (v: number) => void;
}
export function EvolutionSection({
selfEvolve, onSelfEvolveChange,
skillEvolve, onSkillEvolveChange,
skillNudgeInterval, onSkillNudgeIntervalChange,
}: EvolutionSectionProps) {
const { t } = useTranslation("agents");
return (
<section className="space-y-4 rounded-lg border p-3 sm:p-4">
<h3 className="text-sm font-medium">{t("detail.evolution")}</h3>
{/* Self-Evolve */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-violet-500 shrink-0" />
<div className="space-y-0.5">
<Label htmlFor="self-evolve" className="text-sm font-normal cursor-pointer">
{t("general.selfEvolutionLabel")}
</Label>
<p className="text-xs text-muted-foreground">{t("general.selfEvolutionHint")}</p>
</div>
</div>
<Switch id="self-evolve" checked={selfEvolve} onCheckedChange={onSelfEvolveChange} />
</div>
{selfEvolve && (
<div className="flex items-start gap-2 rounded-md border border-violet-200 bg-violet-50 px-3 py-2 text-xs text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("general.selfEvolutionInfo")}</span>
</div>
)}
</div>
{/* Skill Learning */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-amber-500 shrink-0" />
<div className="space-y-0.5">
<Label htmlFor="skill-evolve" className="text-sm font-normal cursor-pointer">
{t("general.skillLearningLabel")}
</Label>
<p className="text-xs text-muted-foreground">{t("general.skillLearningHint")}</p>
</div>
</div>
<Switch id="skill-evolve" checked={skillEvolve} onCheckedChange={onSkillEvolveChange} />
</div>
{skillEvolve && (
<>
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("general.skillLearningInfo")}</span>
</div>
<div className="space-y-1.5">
<Label htmlFor="nudge-interval" className="text-xs font-normal text-muted-foreground">
{t("general.skillNudgeIntervalLabel")}
</Label>
<Input
id="nudge-interval"
type="number"
min={0}
max={100}
value={skillNudgeInterval}
onChange={(e) => onSkillNudgeIntervalChange(Number(e.target.value) || 0)}
className="max-w-[120px] text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("general.skillNudgeIntervalHint")}</p>
</div>
</>
)}
</div>
</section>
);
}
@@ -0,0 +1,135 @@
import { useState } from "react";
import { Heart, Settings, List, Activity } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import type { UseAgentHeartbeatReturn } from "@/pages/agents/hooks/use-agent-heartbeat";
import { HeartbeatConfigDialog } from "../heartbeat-config-dialog";
import { HeartbeatLogsDialog } from "../heartbeat-logs-dialog";
import { formatRelativeTime } from "@/lib/format";
interface HeartbeatCardProps {
heartbeat: UseAgentHeartbeatReturn;
}
export function HeartbeatCard({ heartbeat }: HeartbeatCardProps) {
const { t } = useTranslation("agents");
const { config, loading, saving, toggle, update, test, getChecklist, setChecklist, fetchTargets, refresh, fetchLogs } = heartbeat;
const [configOpen, setConfigOpen] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
if (loading) {
return (
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
{t("heartbeat.loading")}
</div>
</div>
);
}
if (!config) {
return (
<>
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t("heartbeat.title")}</span>
</div>
<Button size="sm" variant="outline" onClick={() => setConfigOpen(true)}>
<Settings className="h-3.5 w-3.5" />
{t("heartbeat.setupButton")}
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("heartbeat.notConfigured")}</p>
</div>
{configOpen && (
<HeartbeatConfigDialog
open={configOpen} onOpenChange={setConfigOpen}
config={config} saving={saving} update={update} test={test}
getChecklist={getChecklist} setChecklist={setChecklist} fetchTargets={fetchTargets} refresh={refresh}
/>
)}
</>
);
}
const intervalMin = Math.round(config.intervalSec / 60);
const activeHours =
config.activeHoursStart && config.activeHoursEnd
? `${config.activeHoursStart}–${config.activeHoursEnd}`
: null;
return (
<>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Heart className={`h-4 w-4 ${config.enabled ? "fill-rose-500 text-rose-500 animate-pulse" : "text-rose-400"}`} />
<span className="text-sm font-medium">{t("heartbeat.title")}</span>
</div>
<Switch
checked={config.enabled}
disabled={saving}
onCheckedChange={(enabled) => toggle(enabled)}
aria-label={t("heartbeat.toggleEnabled")}
/>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
<span>
<Activity className="inline h-3 w-3 mr-0.5" />
{t("heartbeat.every", { interval: intervalMin })}
</span>
{activeHours && <span>{activeHours}</span>}
{config.channel && (
<span>{config.channel}{config.chatId ? `/${config.chatId}` : ""}</span>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{config.lastRunAt && (
<span>
{t("heartbeat.lastRun")}: {formatRelativeTime(config.lastRunAt)}
{config.lastStatus && ` (${config.lastStatus})`}
</span>
)}
{config.nextRunAt && config.enabled && (
<span>{t("heartbeat.nextRun")}: {formatRelativeTime(config.nextRunAt)}</span>
)}
</div>
<div className="flex gap-3">
<span>{t("heartbeat.runs")}: {config.runCount}</span>
<span>{t("heartbeat.suppressed")}: {config.suppressCount}</span>
</div>
</div>
<div className="flex gap-2 pt-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setConfigOpen(true)}>
<Settings className="h-3 w-3" />
{t("heartbeat.configure")}
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setLogsOpen(true)}>
<List className="h-3 w-3" />
{t("heartbeat.logs")}
</Button>
</div>
</div>
{configOpen && (
<HeartbeatConfigDialog
open={configOpen} onOpenChange={setConfigOpen}
config={config} saving={saving} update={update} test={test}
getChecklist={getChecklist} setChecklist={setChecklist} fetchTargets={fetchTargets} refresh={refresh}
/>
)}
{logsOpen && (
<HeartbeatLogsDialog open={logsOpen} onOpenChange={setLogsOpen} fetchLogs={fetchLogs} />
)}
</>
);
}
@@ -0,0 +1,105 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { DollarSign } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
interface ModelBudgetSectionProps {
provider: string;
onProviderChange: (v: string) => void;
model: string;
onModelChange: (v: string) => void;
contextWindow: number;
onContextWindowChange: (v: number) => void;
maxToolIterations: number;
onMaxToolIterationsChange: (v: number) => void;
savedProvider: string;
savedModel: string;
budgetDollars: string;
onBudgetDollarsChange: (v: string) => void;
onSaveBlockedChange?: (blocked: boolean) => void;
}
export function ModelBudgetSection({
provider, onProviderChange, model, onModelChange,
contextWindow, onContextWindowChange,
maxToolIterations, onMaxToolIterationsChange,
savedProvider, savedModel,
budgetDollars, onBudgetDollarsChange,
onSaveBlockedChange,
}: ModelBudgetSectionProps) {
const { t } = useTranslation("agents");
const handleSaveBlockedChange = useCallback((blocked: boolean) => {
onSaveBlockedChange?.(blocked);
}, [onSaveBlockedChange]);
return (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.modelBudget")}</h3>
<ProviderModelSelect
provider={provider}
onProviderChange={onProviderChange}
model={model}
onModelChange={onModelChange}
savedProvider={savedProvider}
savedModel={savedModel}
onSaveBlockedChange={handleSaveBlockedChange}
providerTip="LLM provider name. Must match a configured provider."
modelTip="Model ID to use."
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="contextWindow" className="text-xs">{t("llmConfig.contextWindow")}</Label>
<Input
id="contextWindow"
type="number"
value={contextWindow || ""}
onChange={(e) => onContextWindowChange(Number(e.target.value) || 0)}
placeholder="200000"
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("llmConfig.contextWindowHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="maxToolIterations" className="text-xs">{t("llmConfig.maxToolIterations")}</Label>
<Input
id="maxToolIterations"
type="number"
value={maxToolIterations || ""}
onChange={(e) => onMaxToolIterationsChange(Number(e.target.value) || 0)}
placeholder="25"
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("llmConfig.maxToolIterationsHint")}</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="budget" className="text-xs">
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3 text-emerald-500" />
{t("general.budgetLabel")}
</span>
</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">$</span>
<Input
id="budget"
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={budgetDollars}
onChange={(e) => onBudgetDollarsChange(e.target.value)}
className="max-w-[200px] text-base md:text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">{t("general.budgetHint")}</p>
</div>
</section>
);
}
@@ -0,0 +1,153 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Bot, Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
/** Extract the first emoji grapheme cluster from a string, or return empty. */
function extractSingleEmoji(str: string): string {
const match = str.match(/\p{Emoji_Presentation}(\u200D\p{Emoji_Presentation})*/u)
?? str.match(/\p{Extended_Pictographic}(\uFE0F?\u200D\p{Extended_Pictographic})*/u);
return match?.[0] ?? "";
}
interface PersonalitySectionProps {
agentKey: string;
emoji: string;
onEmojiChange: (v: string) => void;
displayName: string;
onDisplayNameChange: (v: string) => void;
frontmatter: string;
onFrontmatterChange: (v: string) => void;
status: string;
onStatusChange: (v: string) => void;
isDefault: boolean;
onIsDefaultChange: (v: boolean) => void;
}
export function PersonalitySection({
agentKey, emoji, onEmojiChange, displayName, onDisplayNameChange,
frontmatter, onFrontmatterChange, status, onStatusChange, isDefault, onIsDefaultChange,
}: PersonalitySectionProps) {
const { t } = useTranslation("agents");
const [copied, setCopied] = useState(false);
const [emojiEditing, setEmojiEditing] = useState(false);
const copyAgentKey = async () => {
await navigator.clipboard.writeText(agentKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.personality")}</h3>
{/* 2-column: emoji left, fields right */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[auto_1fr]">
{/* Emoji large preview */}
<div className="flex flex-col items-center gap-1.5">
<button
type="button"
onClick={() => setEmojiEditing(true)}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title={t("identity.emojiHint")}
>
{emoji
? <span className="text-2xl leading-none">{emoji}</span>
: <Bot className="h-6 w-6 text-muted-foreground" />}
</button>
{emojiEditing ? (
<Input
autoFocus
value={emoji}
onChange={(e) => onEmojiChange(extractSingleEmoji(e.target.value))}
onBlur={() => setEmojiEditing(false)}
placeholder="🤖"
className="w-14 text-center text-base md:text-sm"
/>
) : (
<span className="text-[10px] text-muted-foreground">{t("identity.emoji")}</span>
)}
</div>
{/* Fields */}
<div className="min-w-0 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="displayName" className="text-xs">{t("identity.displayName")}</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => onDisplayNameChange(e.target.value)}
placeholder={t("identity.displayNamePlaceholder")}
className="text-base font-medium md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="frontmatter" className="text-xs">{t("identity.expertiseSummary")}</Label>
<Textarea
id="frontmatter"
value={frontmatter}
onChange={(e) => onFrontmatterChange(e.target.value)}
placeholder={t("identity.expertiseSummaryPlaceholder")}
rows={3}
className="text-base resize-none md:text-sm"
/>
{frontmatter && (
<p className="text-xs text-muted-foreground italic truncate">
{t("detail.llmSeesAs")}: "{frontmatter}"
</p>
)}
</div>
</div>
</div>
{/* Status + Default on same row */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">{t("identity.status")}</Label>
<Select value={status} onValueChange={onStatusChange}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t("identity.active")}</SelectItem>
<SelectItem value="inactive">{t("identity.inactive")}</SelectItem>
{status === "summon_failed" && (
<SelectItem value="summon_failed" disabled>{t("identity.summonFailed")}</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-1">
<div className="flex items-center gap-2">
<Switch id="isDefault" checked={isDefault} onCheckedChange={onIsDefaultChange} />
<Label htmlFor="isDefault" className="text-sm font-normal">{t("identity.defaultAgent")}</Label>
</div>
</div>
</div>
{/* Agent Key read-only */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{t("identity.agentKey")}</Label>
<div className="flex items-center gap-2">
<span className="flex-1 rounded-md border bg-muted/50 px-3 py-1.5 font-mono text-xs text-muted-foreground truncate">
{agentKey}
</span>
<Button variant="ghost" size="icon" className="shrink-0 size-7" onClick={copyAgentKey}>
{copied
? <Check className="h-3 w-3 text-green-500" />
: <Copy className="h-3 w-3" />}
</Button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,115 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { SearchInput } from "@/components/shared/search-input";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { useAgentSkills } from "../../hooks/use-agent-skills";
interface SkillsSectionProps {
agentId: string;
}
const visibilityVariant = (v: string) => {
switch (v) {
case "public": return "success";
case "internal": return "secondary";
default: return "outline";
}
};
export function SkillsSection({ agentId }: SkillsSectionProps) {
const { t } = useTranslation("agents");
const { skills, loading, grantSkill, revokeSkill } = useAgentSkills(agentId);
const [search, setSearch] = useState("");
const [toggling, setToggling] = useState<string | null>(null);
const filtered = skills
.filter((s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.slug.toLowerCase().includes(search.toLowerCase()) ||
s.description.toLowerCase().includes(search.toLowerCase()),
)
.sort((a, b) => {
const rank = (s: typeof a) => s.granted ? 0 : s.is_system ? 1 : 2;
return rank(a) - rank(b);
});
const handleToggle = async (skillId: string, granted: boolean) => {
setToggling(skillId);
try {
if (granted) await revokeSkill(skillId);
else await grantSkill(skillId);
} finally {
setToggling(null);
}
};
return (
<section className="space-y-3 rounded-lg border p-3 sm:p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-500" />
<h3 className="text-sm font-medium">{t("detail.skills")}</h3>
{!loading && (
<span className="text-xs text-muted-foreground">
({skills.filter((s) => s.granted).length}/{skills.length})
</span>
)}
</div>
<SearchInput
value={search}
onChange={setSearch}
placeholder={t("skills.filterSkills")}
className="w-40 sm:w-52"
/>
</div>
{loading && skills.length === 0 ? (
<TableSkeleton />
) : skills.length === 0 ? (
<p className="text-xs text-muted-foreground italic px-1">{t("skills.noSkillsAvailable")}</p>
) : (
<div className="divide-y rounded-lg border max-h-[300px] overflow-y-auto overscroll-contain">
{filtered.map((skill) => (
<div key={skill.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">{skill.name}</span>
<Badge variant={visibilityVariant(skill.visibility)} className="text-[10px] shrink-0">
{skill.visibility}
</Badge>
{skill.is_system && (
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px] shrink-0">
{t("skills.system")}
</Badge>
)}
</div>
{skill.description && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{skill.description}</p>
)}
</div>
{skill.is_system ? (
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{t("skills.alwaysAvailable")}
</span>
) : (
<Switch
checked={skill.granted}
disabled={toggling === skill.id}
onCheckedChange={() => handleToggle(skill.id, skill.granted)}
/>
)}
</div>
))}
{filtered.length === 0 && (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{t("skills.noSkillsMatch")}
</div>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,223 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { toast } from "@/stores/use-toast-store";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
/** Matches Go AgentHeartbeat JSON tags (camelCase). */
export interface HeartbeatConfig {
id: string;
agentId: string;
enabled: boolean;
intervalSec: number;
ackMaxChars: number;
maxRetries: number;
isolatedSession: boolean;
lightContext: boolean;
activeHoursStart?: string;
activeHoursEnd?: string;
timezone?: string;
channel?: string;
chatId?: string;
nextRunAt?: string;
lastRunAt?: string;
lastStatus?: string;
lastError?: string;
runCount: number;
suppressCount: number;
}
export interface DeliveryTarget {
channel: string;
chatId: string;
title?: string;
kind: string; // "dm" | "group"
}
export interface HeartbeatLog {
id: string;
status: string;
summary?: string;
error?: string;
duration_ms?: number;
input_tokens?: number;
output_tokens?: number;
skip_reason?: string;
ran_at: string;
}
/** Return type of useAgentHeartbeat, for passing as props. */
export type UseAgentHeartbeatReturn = ReturnType<typeof useAgentHeartbeat>;
export function useAgentHeartbeat(agentId: string) {
const ws = useWs();
const [config, setConfig] = useState<HeartbeatConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!ws.isConnected || !agentId) return;
setLoading(true);
setError(null);
try {
const res = await ws.call<{ heartbeat: HeartbeatConfig | null }>(
Methods.HEARTBEAT_GET,
{ agentId },
);
setConfig(res.heartbeat);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load heartbeat";
setError(msg);
toast.error(msg);
} finally {
setLoading(false);
}
}, [ws, agentId]);
useEffect(() => {
if (ws.isConnected && agentId) {
refresh();
}
}, [ws.isConnected, agentId, refresh]);
// Auto-refresh when countdown reaches 0: poll every 5s until backend returns a new nextRunAt.
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
// Only poll when enabled and nextRunAt is in the past (or just passed).
const nextRunAt = config?.nextRunAt;
const enabled = config?.enabled;
if (!enabled || !nextRunAt || !ws.isConnected) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
return;
}
const nextMs = new Date(nextRunAt).getTime();
const nowMs = Date.now();
if (nextMs > nowMs) {
// Still in the future — schedule poll to start when it expires.
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
const delay = nextMs - nowMs + 1000; // 1s after expiry
const timeout = setTimeout(() => {
// Start polling.
refresh();
pollRef.current = setInterval(() => refresh(), 5000);
}, delay);
return () => { clearTimeout(timeout); if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
}
// Already expired — start polling immediately.
if (!pollRef.current) {
refresh();
pollRef.current = setInterval(() => refresh(), 5000);
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
}, [config?.enabled, config?.nextRunAt, ws.isConnected, refresh]);
const toggle = useCallback(
async (enabled: boolean) => {
if (!agentId) return;
setSaving(true);
try {
await ws.call(Methods.HEARTBEAT_TOGGLE, { agentId, enabled });
setConfig((prev) => (prev ? { ...prev, enabled } : prev));
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to toggle heartbeat";
toast.error(msg);
} finally {
setSaving(false);
}
},
[ws, agentId],
);
const update = useCallback(
async (params: Partial<HeartbeatConfig>) => {
if (!agentId) return;
setSaving(true);
setError(null);
try {
const res = await ws.call<{ heartbeat: HeartbeatConfig }>(
Methods.HEARTBEAT_SET,
{ agentId, ...params },
);
setConfig(res.heartbeat);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save heartbeat";
setError(msg);
toast.error(msg);
throw err;
} finally {
setSaving(false);
}
},
[ws, agentId],
);
const test = useCallback(async () => {
if (!agentId) return;
setSaving(true);
try {
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";
toast.error(msg);
} finally {
setSaving(false);
}
}, [ws, agentId]);
const fetchLogs = useCallback(
async (limit = 20, offset = 0): Promise<{ logs: HeartbeatLog[]; total: number }> => {
if (!agentId) return { logs: [], total: 0 };
const res = await ws.call<{ logs: HeartbeatLog[]; total: number }>(
Methods.HEARTBEAT_LOGS,
{ agentId, limit, offset },
);
return { logs: res.logs ?? [], total: res.total ?? 0 };
},
[ws, agentId],
);
const getChecklist = useCallback(async (): Promise<string> => {
if (!agentId) return "";
const res = await ws.call<{ content: string }>(
Methods.HEARTBEAT_CHECKLIST_GET,
{ agentId },
);
return res.content ?? "";
}, [ws, agentId]);
const setChecklist = useCallback(
async (content: string) => {
if (!agentId) return;
await ws.call(Methods.HEARTBEAT_CHECKLIST_SET, { agentId, content });
},
[ws, agentId],
);
const fetchTargets = useCallback(async (): Promise<DeliveryTarget[]> => {
if (!agentId || !ws.isConnected) return [];
const res = await ws.call<{ targets: DeliveryTarget[] }>(
Methods.HEARTBEAT_TARGETS, { agentId },
);
return res.targets ?? [];
}, [ws, agentId]);
return {
config,
loading,
saving,
error,
toggle,
update,
test,
fetchLogs,
getChecklist,
setChecklist,
fetchTargets,
refresh,
};
}