From 1ae5c7641aadc3c586b785eda6f2fb8fcd1a1d83 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 18 Mar 2026 16:37:53 +0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20refactor=20agent=20detail=20page=20?= =?UTF-8?q?=E2=80=94=203=20tabs,=20heartbeat=20UI,=20compact=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/web/src/api/protocol.ts | 12 + .../src/components/shared/sticky-save-bar.tsx | 4 +- ui/web/src/hooks/use-countdown.ts | 33 ++ ui/web/src/i18n/locales/en/agents.json | 70 ++- ui/web/src/i18n/locales/vi/agents.json | 70 ++- ui/web/src/i18n/locales/zh/agents.json | 70 ++- .../agent-detail/agent-advanced-dialog.tsx | 163 +++++++ .../agents/agent-detail/agent-config-tab.tsx | 177 -------- .../agents/agent-detail/agent-danger-tab.tsx | 60 --- .../agents/agent-detail/agent-detail-page.tsx | 216 ++++------ .../agent-detail/agent-display-utils.ts | 17 + .../agents/agent-detail/agent-general-tab.tsx | 260 ----------- .../agents/agent-detail/agent-header.tsx | 131 ++++++ .../agent-detail/agent-overview-tab.tsx | 175 ++++++++ .../agents/agent-detail/agent-skills-tab.tsx | 129 ------ .../general-sections/identity-section.tsx | 151 ------- .../agent-detail/general-sections/index.ts | 2 - .../general-sections/llm-config-section.tsx | 77 ---- .../agent-detail/heartbeat-config-dialog.tsx | 404 ++++++++++++++++++ .../agent-detail/heartbeat-logs-dialog.tsx | 165 +++++++ .../capabilities-section.tsx | 58 +++ .../overview-sections/evolution-section.tsx | 89 ++++ .../overview-sections/heartbeat-card.tsx | 135 ++++++ .../model-budget-section.tsx | 105 +++++ .../overview-sections/personality-section.tsx | 153 +++++++ .../overview-sections/skills-section.tsx | 115 +++++ .../pages/agents/hooks/use-agent-heartbeat.ts | 223 ++++++++++ 27 files changed, 2261 insertions(+), 1003 deletions(-) create mode 100644 ui/web/src/hooks/use-countdown.ts create mode 100644 ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx delete mode 100644 ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx delete mode 100644 ui/web/src/pages/agents/agent-detail/agent-danger-tab.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/agent-display-utils.ts delete mode 100644 ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/agent-header.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx delete mode 100644 ui/web/src/pages/agents/agent-detail/agent-skills-tab.tsx delete mode 100644 ui/web/src/pages/agents/agent-detail/general-sections/identity-section.tsx delete mode 100644 ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/heartbeat-logs-dialog.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/capabilities-section.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/evolution-section.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/heartbeat-card.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/model-budget-section.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/personality-section.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/skills-section.tsx create mode 100644 ui/web/src/pages/agents/hooks/use-agent-heartbeat.ts diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 1cb31df5..3f15866e 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -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 */ diff --git a/ui/web/src/components/shared/sticky-save-bar.tsx b/ui/web/src/components/shared/sticky-save-bar.tsx index a09930d8..acf0a155 100644 --- a/ui/web/src/components/shared/sticky-save-bar.tsx +++ b/ui/web/src/components/shared/sticky-save-bar.tsx @@ -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({ {resolvedSavedLabel} )} - diff --git a/ui/web/src/hooks/use-countdown.ts b/ui/web/src/hooks/use-countdown.ts new file mode 100644 index 00000000..bfe43c27 --- /dev/null +++ b/ui/web/src/hooks/use-countdown.ts @@ -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); +} diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 8ddeb17d..4e52b218 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index e7e2eabf..6d9a1cf6 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 98a85998..b25335f6 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -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": "创建代理失败", diff --git a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx new file mode 100644 index 00000000..44f9882e --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx @@ -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) => Promise; +} + +export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: AgentAdvancedDialogProps) { + const { t } = useTranslation("agents"); + + const deriveState = (a: AgentData) => { + const otherObj = (a.other_config ?? {}) as Record; + 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(init.wsSharing); + const [thinkingLevel, setThinkingLevel] = useState(init.thinkingLevel); + const [comp, setComp] = useState(init.comp); + const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled); + const [prune, setPrune] = useState(init.prune); + const [sbEnabled, setSbEnabled] = useState(init.sbEnabled); + const [sb, setSb] = useState(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(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 | null) ?? {}; + const otherBase: Record = { ...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 ( + + + + + + {t("detail.advanced")} + + + + {/* Scrollable body */} +
+ {/* Workspace (read-only) */} + + + {/* Workspace Sharing */} + + + {/* Thinking */} + + + {/* Performance */} + +
+ + { setPruneEnabled(v); if (!v) setPrune({}); }} + onChange={setPrune} + /> + { setSbEnabled(v); if (!v) setSb({}); }} + onChange={setSb} + /> +
+
+ + {/* Footer */} +
+ {saveError && ( +

{saveError}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx deleted file mode 100644 index e528b737..00000000 --- a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx +++ /dev/null @@ -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) => Promise; -} - -export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { - const { t } = useTranslation("agents"); - - const [subEnabled, setSubEnabled] = useState(agent.subagents_config != null); - const [sub, setSub] = useState(agent.subagents_config ?? {}); - - const [toolsEnabled, setToolsEnabled] = useState(agent.tools_config != null); - const [tools, setTools] = useState(agent.tools_config ?? {}); - - const [comp, setComp] = useState(agent.compaction_config ?? {}); - - const [pruneEnabled, setPruneEnabled] = useState(agent.context_pruning != null); - const [prune, setPrune] = useState(agent.context_pruning ?? {}); - - const [sbEnabled, setSbEnabled] = useState(agent.sandbox_config != null); - const [sb, setSb] = useState(agent.sandbox_config ?? {}); - - const [memEnabled, setMemEnabled] = useState(agent.memory_config != null); - const [mem, setMem] = useState(agent.memory_config ?? {}); - - const otherObj = (agent.other_config ?? {}) as Record; - const initialThinkingLevel = (typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off"); - const initialWsSharing = (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig; - - const [wsSharing, setWsSharing] = useState(initialWsSharing); - - const [thinkingLevel, setThinkingLevel] = useState(initialThinkingLevel); - - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - const [saved, setSaved] = useState(false); - - const handleSave = async () => { - setSaving(true); - setSaveError(null); - setSaved(false); - try { - const updates: Record = { - 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 | null) ?? {}; - const otherBase: Record = { ...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 ( -
- {/* Workspace & Security */} - - - {/* Core */} - - - {/* Capabilities */} - -
- { setMemEnabled(v); if (!v) setMem({}); }} - onChange={setMem} - /> - { setSubEnabled(v); if (!v) setSub({}); }} - onChange={setSub} - /> - { setToolsEnabled(v); if (!v) setTools({}); }} - onChange={setTools} - /> -
- - {/* Performance */} - -
- - { setPruneEnabled(v); if (!v) setPrune({}); }} - onChange={setPrune} - /> - { setSbEnabled(v); if (!v) setSb({}); }} - onChange={setSb} - /> -
- - - -
- ); -} diff --git a/ui/web/src/pages/agents/agent-detail/agent-danger-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-danger-tab.tsx deleted file mode 100644 index 573c367b..00000000 --- a/ui/web/src/pages/agents/agent-detail/agent-danger-tab.tsx +++ /dev/null @@ -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; - onDeleted: () => void; -} - -export function AgentDangerTab({ agent, onDelete, onDeleted }: AgentDangerTabProps) { - const { t } = useTranslation("agents"); - const [confirmOpen, setConfirmOpen] = useState(false); - - return ( -
-
-
-
-

{t("delete.title")}

-

- {t("delete.description")} -

-
- -
- {agent.is_default && ( -

- {t("delete.defaultCannotDelete")} -

- )} -
- - { - await onDelete(); - setConfirmOpen(false); - onDeleted(); - }} - /> -
- ); -} diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index ea1296e3..5b2f6dfb 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -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 ; + return ; } const title = agentDisplayName(agent, t("card.unnamedAgent")); - const subtitle = agentSubtitle(agent); - const emoji = typeof (agent.other_config as Record | null)?.emoji === "string" - ? (agent.other_config as Record).emoji as string - : ""; return ( - -
- {/* Header */} -
- -
- {emoji ? {emoji} : } -
-
-
-

{title}

- {agent.is_default && ( - - )} - - {agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status} - -
-
- {subtitle && ( - <> - {subtitle} - | - - )} - - - {agent.agent_type} - - - {agent.agent_type === "predefined" - ? t("card.predefinedTooltip") - : t("card.openTooltip")} - - - {agent.agent_type === "predefined" && (() => { - const evolving = Boolean((agent.other_config as Record | null)?.self_evolve); - return ( - - - - - {evolving ? t("detail.evolving") : t("detail.static")} - - - - {evolving - ? t("detail.evolvingTooltipDetail") - : t("detail.staticTooltipDetail")} - - - ); - })()} - {agent.provider && ( - <> - | - {agent.provider} / {agent.model} - - )} -
-
- -
+
+ setDeleteOpen(true)} + onAdvanced={() => setAdvancedOpen(true)} + onHeartbeat={() => setHeartbeatOpen(true)} + /> - {/* Tabs */} -
- - - {t("detail.tabs.general")} - {t("detail.tabs.config")} - {t("detail.tabs.files")} - {t("detail.tabs.skills")} - {agent.agent_type === "predefined" && ( - {t("detail.tabs.instances")} - )} - +
+
+ + + {t("detail.tabs.agent")} + {t("detail.tabs.files")} + {agent.agent_type === "predefined" && ( + {t("detail.tabs.instances")} + )} + - - - - - - - - - - - - - - - - - {agent.agent_type === "predefined" && ( - - + + - )} - + + + + + {agent.agent_type === "predefined" && ( + + + + )} + +
+ + { await resummonAgent(); }} /> + {heartbeatOpen && ( + + )} +
- ); } diff --git a/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts b/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts new file mode 100644 index 00000000..39ff1575 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts @@ -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; +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx deleted file mode 100644 index 16e2a7dd..00000000 --- a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx +++ /dev/null @@ -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) => Promise; -} - -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; - 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(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 ( -
- - - {/* Self-Evolve (predefined agents only) — right after Identity */} - {agent.agent_type === "predefined" && ( - <> - -
-
- -

{t("general.selfEvolution")}

-
-
-
- -

- {t("general.selfEvolutionHint")} -

-
- -
- {selfEvolve && ( -
- - {t("general.selfEvolutionInfo")} -
- )} -
- - )} - - {/* Skill Learning (predefined agents only) — right after Self-Evolve */} - {agent.agent_type === "predefined" && ( - <> - -
-
- -

{t("general.skillLearning")}

-
-
-
- -

- {t("general.skillLearningHint")} -

-
- -
- {skillEvolve && ( - <> -
- - {t("general.skillLearningInfo")} -
-
- - setSkillNudgeInterval(Number(e.target.value) || 0)} - className="max-w-[120px] text-base md:text-sm" - /> -

- {t("general.skillNudgeIntervalHint")} -

-
- - )} -
- - )} - - - - - - - - - - {/* Budget */} - -
-
- -

{t("general.budget")}

-
-
- -

- {t("general.budgetHint")} -

-
- setBudgetDollars(e.target.value)} - className="max-w-[200px]" - /> -
- - {/* Save */} - {saveError && ( -
- - {saveError} -
- )} -
- {saved && ( - - {t("general.saved")} - - )} - -
-
- ); -} diff --git a/ui/web/src/pages/agents/agent-detail/agent-header.tsx b/ui/web/src/pages/agents/agent-detail/agent-header.tsx new file mode 100644 index 00000000..daf2b96f --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/agent-header.tsx @@ -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; + 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 ( + +
+ + + {/* Emoji avatar */} +
+ {emoji + ? {emoji} + : } +
+ + {/* Agent info */} +
+
+

{title}

+ {agent.is_default && ( + + )} + + {agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status} + + + + {agent.agent_type} + + + {agent.agent_type === "predefined" ? t("card.predefinedTooltip") : t("card.openTooltip")} + + + {agent.agent_type === "predefined" && ( + + + + + {selfEvolve ? t("detail.evolving") : t("detail.static")} + + + + {selfEvolve ? t("detail.evolvingTooltipDetail") : t("detail.staticTooltipDetail")} + + + )} +
+
+ {keyDisplay} + {agent.provider && ( + <> + · + {agent.provider} / {agent.model} + + )} +
+
+ + {/* Heartbeat action */} + + + +
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx new file mode 100644 index 00000000..b717b182 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx @@ -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) => Promise; + heartbeat: UseAgentHeartbeatReturn; +} + +export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTabProps) { + const { t } = useTranslation("agents"); + + const otherCfg = (agent.other_config ?? {}) as Record; + + // 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(agent.memory_config ?? {}); + const [subEnabled, setSubEnabled] = useState(agent.subagents_config != null); + const [sub, setSub] = useState(agent.subagents_config ?? {}); + const [toolsEnabled, setToolsEnabled] = useState(agent.tools_config != null); + const [tools, setTools] = useState(agent.tools_config ?? {}); + + // Save state + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(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 ( +
+ + + + + + + + + {agent.agent_type === "predefined" && ( + + )} + + + + +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-skills-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-skills-tab.tsx deleted file mode 100644 index 2e883c64..00000000 --- a/ui/web/src/pages/agents/agent-detail/agent-skills-tab.tsx +++ /dev/null @@ -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(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 ; - } - - if (!loading && skills.length === 0) { - return ( - - ); - } - - return ( -
-
-

- {t("skills.skillsGranted", { - granted: skills.filter((s) => s.granted).length, - total: skills.length, - })} -

- -
- -
- {filtered.map((skill) => ( -
-
-
- {skill.name} - - {skill.visibility} - - {skill.is_system && ( - - {t("skills.system")} - - )} -
- {skill.description && ( -

{skill.description}

- )} -
- {skill.is_system ? ( - {t("skills.alwaysAvailable")} - ) : ( - handleToggle(skill.id, skill.granted)} - /> - )} -
- ))} - {filtered.length === 0 && ( -
- {t("skills.noSkillsMatch")} -
- )} -
-
- ); -} diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/identity-section.tsx b/ui/web/src/pages/agents/agent-detail/general-sections/identity-section.tsx deleted file mode 100644 index f6a2d178..00000000 --- a/ui/web/src/pages/agents/agent-detail/general-sections/identity-section.tsx +++ /dev/null @@ -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 ( -
-

{t("identity.title")}

-
-
- -
- - -
-

- {t("identity.agentKeyHint")} -

-
-
- -
- { - 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")} - /> - onDisplayNameChange(e.target.value)} - placeholder={t("identity.displayNamePlaceholder")} - /> -
-

- {t("identity.displayNameHint")} -

-
-
- - onFrontmatterChange(e.target.value)} - placeholder={t("identity.expertiseSummaryPlaceholder")} - /> -

- {t("identity.expertiseSummaryHint")} -

-
-
-
- - -
-
-
- - -
-
-
-
-
- ); -} diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/index.ts b/ui/web/src/pages/agents/agent-detail/general-sections/index.ts index cc4a1091..ceaef95f 100644 --- a/ui/web/src/pages/agents/agent-detail/general-sections/index.ts +++ b/ui/web/src/pages/agents/agent-detail/general-sections/index.ts @@ -1,3 +1 @@ -export { IdentitySection } from "./identity-section"; -export { LlmConfigSection } from "./llm-config-section"; export { WorkspaceSection } from "./workspace-section"; diff --git a/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx b/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx deleted file mode 100644 index 3ea3c58b..00000000 --- a/ui/web/src/pages/agents/agent-detail/general-sections/llm-config-section.tsx +++ /dev/null @@ -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 ( -
-

{t("llmConfig.title")}

-
- -
-
- - onContextWindowChange(Number(e.target.value) || 0)} - placeholder="200000" - /> -

{t("llmConfig.contextWindowHint")}

-
-
- - onMaxToolIterationsChange(Number(e.target.value) || 0)} - placeholder="25" - /> -

{t("llmConfig.maxToolIterationsHint")}

-
-
-
-
- ); -} diff --git a/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx new file mode 100644 index 00000000..1b6c11fb --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx @@ -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) => Promise; + test: () => Promise; + getChecklist: () => Promise; + setChecklist: (content: string) => Promise; + fetchTargets: () => Promise; + refresh: () => Promise; +} + +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([]); + + 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 ( + + + + + + {t("heartbeat.configTitle")} + + {enabled ? t("heartbeat.on") : t("heartbeat.off")} + + + + + {/* Scrollable body — standard pattern: -mx-4 px-4 */} +
+ + {/* ── Enable + Interval (top priority) ── */} +
+
+ +
+ {t("heartbeat.enabled")} +

{t("heartbeat.enabledHint")}

+
+
+
+ + setIntervalMin(Math.max(5, Number(e.target.value) || 5))} + className="w-16 text-center text-base md:text-sm" + /> + min +
+
+ + {/* ── Delivery — WHERE it sends ── */} +
+
+ +

+ {t("heartbeat.sectionDelivery")} +

+
+
+
+ + {channelNames.length > 0 ? ( + + ) : ( + setChannel(e.target.value)} + className="text-base md:text-sm" + /> + )} +
+
+ + {(() => { + if (!channel) { + return ( + + ); + } + const filtered = targets.filter((t) => t.channel === channel); + if (filtered.length > 0) { + return ( + + ); + } + return ( + setChatId(e.target.value)} + className="text-base md:text-sm" + /> + ); + })()} +
+
+
+ + {/* ── Schedule — WHEN it runs ── */} +
+
+ +

+ {t("heartbeat.sectionSchedule")} +

+
+
+
+ + setActiveHoursStart(e.target.value)} + className="text-base md:text-sm" + /> +
+
+ + setActiveHoursEnd(e.target.value)} + className="text-base md:text-sm" + /> +
+
+ + setTimezone(e.target.value)} + className="text-base md:text-sm" + /> +
+
+

{t("heartbeat.scheduleHint")}

+
+ + {/* ── Checklist — WHAT the agent does ── */} +
+
+ +

+ {t("heartbeat.checklist")} +

+
+

{t("heartbeat.checklistHint")}

+ {checklistLoading ? ( +
+ + {t("heartbeat.checklistLoading")} +
+ ) : ( +