diff --git a/desktop/frontend-next/src/i18n/msg/settings.ts b/desktop/frontend-next/src/i18n/msg/settings.ts index dbb340ea8e..b10df866af 100644 --- a/desktop/frontend-next/src/i18n/msg/settings.ts +++ b/desktop/frontend-next/src/i18n/msg/settings.ts @@ -55,6 +55,8 @@ export const SETTINGS: Messages = { "settings.global_shortcuts_are_not_supported": { zh: "当前环境不支持全局快捷键。", en: "Global shortcuts are not supported in this environment." }, "settings.global_show_hide_shortcut": { zh: "全局显示 / 隐藏快捷键", en: "Global show / hide shortcut" }, "settings.gone_from_the_next_launch": { zh: "下次启动时不再显示,本次仍保留", en: "Gone from the next launch on; still here for this one" }, + "settings.hooks": { zh: "钩子", en: "Hooks" }, + "settings.hooks_hint": { zh: "在工具调用前后运行你自己的命令", en: "Run your own commands before and after tool calls" }, "settings.how_it_thinks_how_it": { zh: "它怎么想、怎么问你、怎么协作", en: "How it thinks, how it asks you, how it works with others" }, "settings.icon_rail": { zh: "图标栏", en: "Icon rail" }, "settings.idle_page_sleep": { zh: "空闲网页休眠", en: "Idle page sleep" }, @@ -84,8 +86,11 @@ export const SETTINGS: Messages = { "settings.n_10_minutes": { zh: "10 分钟", en: "10 minutes" }, "settings.n_30_minutes": { zh: "30 分钟", en: "30 minutes" }, "settings.n_4_hours": { zh: "4 小时", en: "4 hours" }, + "settings.n_hooks": { zh: "{n} 条", en: "{n}" }, "settings.n_items": { zh: "{n} 项", en: "{n} items" }, "settings.n_packages": { zh: "{n} 个包", en: "{n} packages" }, + "settings.n_paused": { zh: ",{n} 个暂停", en: ", {n} paused" }, + "settings.n_scheduled": { zh: "{n} 个", en: "{n}" }, "settings.n_services": { zh: "{n} 个服务", en: "{n} services" }, "settings.n_with_problems": { zh: "{n} 个异常", en: "{n} with problems" }, "settings.narration_between_tool_calls": { zh: "回合间叙述", en: "Narration between tool calls" }, @@ -132,6 +137,7 @@ export const SETTINGS: Messages = { "settings.runtime": { zh: "运行时", en: "Runtime" }, "settings.sandbox": { zh: "沙箱", en: "Sandbox" }, "settings.sandbox_and_execution": { zh: "沙箱与执行", en: "Sandbox and execution" }, + "settings.scheduled_runs_hint": { zh: "到点让 Agent 只读地跑一遍并留下报告", en: "Have the agent run a read-only pass on a schedule and leave a report" }, "settings.search_conversations": { zh: "搜索会话", en: "Search conversations" }, "settings.search_settings": { zh: "搜索设置", en: "Search settings" }, "settings.send_after_the_whole_run": { zh: "整轮结束后发送", en: "Send after the whole run" }, diff --git a/desktop/frontend-next/src/ui/AgentSettings.test.tsx b/desktop/frontend-next/src/ui/AgentSettings.test.tsx index 1ecd6e0a99..1ea98ca3ce 100644 --- a/desktop/frontend-next/src/ui/AgentSettings.test.tsx +++ b/desktop/frontend-next/src/ui/AgentSettings.test.tsx @@ -19,10 +19,10 @@ function draw() { return go; } -it("is three cards of single rows, plan mode and the display switches living elsewhere", async () => { +it("is four cards of single rows, plan mode and the display switches living elsewhere", async () => { draw(); - expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["人设与预设", "计划与提问", "协作"]); - for (const id of ["persona", "agent-presets", "plan-approval", "ask-timeout", "turn-narration", "progress-watch", "session-ref", "subagent-claims", "peers"]) { + expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["人设与预设", "计划与提问", "协作", "自动化"]); + for (const id of ["persona", "agent-presets", "plan-approval", "ask-timeout", "turn-narration", "progress-watch", "session-ref", "subagent-claims", "peers", "hooks", "scheduled-runs"]) { await waitFor(() => expect(document.querySelector(`[data-setting="${id}"]`), id).toBeTruthy()); } for (const id of ["plan-mode", "changed-files", "fold-work", "feedback-level-notices"]) expect(document.querySelector(`[data-setting="${id}"]`), id).toBeNull(); @@ -35,3 +35,16 @@ it("opens the presets page from both the persona row and the presets row", () => fireEvent.click(screen.getByRole("button", { name: "管理预设" })); expect(go).toHaveBeenLastCalledWith("agent-presets"); }); + +it("opens the hooks and the scheduled-runs pages from the automation card", async () => { + const go = draw(); + const card = [...document.querySelectorAll(".prefs-card")].find((c) => c.querySelector("header h3")?.textContent === "自动化")!; + const buttons = [...card.querySelectorAll("button")].filter((b) => b.textContent === "管理"); + expect(buttons).toHaveLength(2); + fireEvent.click(buttons[0]); + expect(go).toHaveBeenLastCalledWith("hooks"); + fireEvent.click(buttons[1]); + expect(go).toHaveBeenLastCalledWith("scheduled-runs"); + expect(card.textContent).toContain("在工具调用前后运行你自己的命令"); + expect(card.textContent).toContain("到点让 Agent 只读地跑一遍并留下报告"); +}); diff --git a/desktop/frontend-next/src/ui/AgentSettings.tsx b/desktop/frontend-next/src/ui/AgentSettings.tsx index 7964d67e08..df6d0e6155 100644 --- a/desktop/frontend-next/src/ui/AgentSettings.tsx +++ b/desktop/frontend-next/src/ui/AgentSettings.tsx @@ -1,8 +1,11 @@ -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; import { t } from "../i18n"; import type { HubPort } from "../port/hub"; import type { AgentPort } from "../port/port"; import { AgentPresets } from "./AgentPresets"; +import { Hooks } from "./Hooks"; +import { Group } from "./Group"; +import { ScheduledRuns } from "./ScheduledRuns"; import { ApplyNote } from "./Group"; import { AskTimeout } from "./AskTimeout"; import { PeersSection } from "./PeersSection"; @@ -21,8 +24,26 @@ interface Props { onChanged: () => void; } +// How many of each there are, for the card's rows. A count that cannot be read +// is left out rather than guessed. +function useAutomationCounts(port: AgentPort) { + const [counts, setCounts] = useState<{ hooks?: number; schedules?: number; paused?: number }>({}); + useEffect(() => { + let live = true; + void port.hooks().then((c) => live && setCounts((was) => ({ ...was, hooks: c.hooks.length }))).catch(() => {}); + void port.schedules().then((b) => live && setCounts((was) => ({ ...was, schedules: b.schedules.length, paused: b.schedules.filter((s) => s.status === "paused").length }))).catch(() => {}); + return () => { live = false; }; + }, [port]); + return counts; +} + export function AgentSettings({ port, hub, persona, onChanged }: Props) { const deep = useContext(SettingDeepContext); + const counts = useAutomationCounts(port); + const hooksHint = t("settings.hooks_hint") + (counts.hooks ? ` · ${t("settings.n_hooks", { n: counts.hooks })}` : ""); + const runsHint = t("settings.scheduled_runs_hint") + (counts.schedules + ? ` · ${t("settings.n_scheduled", { n: counts.schedules })}${counts.paused ? t("settings.n_paused", { n: counts.paused }) : ""}` + : ""); return ( <> @@ -51,6 +72,12 @@ export function AgentSettings({ port, hub, persona, onChanged }: Props) { + + + + + + ); } diff --git a/desktop/frontend-next/src/ui/ScheduledRuns.tsx b/desktop/frontend-next/src/ui/ScheduledRuns.tsx index e03b7beecb..ea138a67c6 100644 --- a/desktop/frontend-next/src/ui/ScheduledRuns.tsx +++ b/desktop/frontend-next/src/ui/ScheduledRuns.tsx @@ -28,7 +28,7 @@ const SKIP: Record = { missed: "panels.missed_its_time", budget_ * shown back as a preview with the most it can spend, and only that preview's * own confirmation creates it. A report is the model's text: it is labelled as * such and drawn as plain text, never as markup. */ -export function ScheduledRuns({ port }: { port: AgentPort }) { +export function ScheduledRuns({ port, hint, pill }: { port: AgentPort; hint?: string; pill?: string }) { const [board, setBoard] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -76,7 +76,7 @@ export function ScheduledRuns({ port }: { port: AgentPort }) { ); if (!board) { return unreadable ? ( - +
{unreadable}
) : null; @@ -87,7 +87,7 @@ export function ScheduledRuns({ port }: { port: AgentPort }) { const draft = (patch: Partial) => setForm((f) => (f ? { ...f, ...patch } : f)); return ( - +
{unreadable}
diff --git a/desktop/frontend-next/src/ui/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index 65182e4fe0..f554217803 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -35,8 +35,6 @@ import { InstalledLocation } from "./InstalledLocation"; import { ModelSettings } from "./ModelSettings"; import { ServerRow } from "./ServerRow"; import { SkillGroups } from "./SkillRow"; -import { Hooks } from "./Hooks"; -import { ScheduledRuns } from "./ScheduledRuns"; import { ConfigTrouble } from "./ConfigTrouble"; import { ModHost } from "./ModHost"; import { DataSettings } from "./DataSettings"; @@ -556,17 +554,6 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort, )} )} - {at === "ext" && extTab === "installed" && ( - <> - - - - - - )} {at === "network" && ( )} diff --git a/desktop/frontend-next/src/ui/prefsnav.tsx b/desktop/frontend-next/src/ui/prefsnav.tsx index 28c4f7dd5a..2bb6549dc2 100644 --- a/desktop/frontend-next/src/ui/prefsnav.tsx +++ b/desktop/frontend-next/src/ui/prefsnav.tsx @@ -238,8 +238,8 @@ export const SETTINGS: SettingEntry[] = [ // takes that choice as a parameter — hook.Scope, McpInstallScope, the skill // activation scope, a memory's project/global flag. Naming one authority here // would be wrong whenever the reader picks the other. - { section: "hooks", anchor: "scheduled-runs", page: true, title: "panels.scheduled_runs", scope: "machine", apply: "immediate", keywords: ["定时", "计划", "任务", "每天", "schedule", "cron", "unattended", "无人值守"] }, - { section: "hooks", anchor: "hooks", page: true, title: "settings.automation", scope: "chosen", apply: "immediate", keywords: ["钩子", "hook", "触发"] }, + { section: "session", anchor: "scheduled-runs", page: true, title: "panels.scheduled_runs", scope: "machine", apply: "immediate", keywords: ["定时", "计划", "任务", "每天", "schedule", "cron", "unattended", "无人值守"] }, + { section: "session", anchor: "hooks", page: true, title: "settings.hooks", scope: "chosen", apply: "immediate", keywords: ["钩子", "hook", "触发"] }, { section: "ext", anchor: "market", title: "ext.community_market", scope: "machine", apply: "immediate", keywords: ["市场", "market", "社区", "发现", "下载", "发布", "publish", "主题"] }, { section: "ext", anchor: "plugins", title: "settings.plugin_packages", scope: "machine", apply: "immediate", keywords: ["安装"] }, diff --git a/desktop/frontend-next/src/ui/settings-search-language.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-search-language.interaction.test.tsx index 88d42637d3..c0582fa195 100644 --- a/desktop/frontend-next/src/ui/settings-search-language.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/settings-search-language.interaction.test.tsx @@ -44,7 +44,7 @@ async function draw() { return { calls, onClose, find: () => screen.getByRole("textbox", { name: t("settings.search_settings") }) }; } -it.each(["market", "plugins", "mcp", "skills", "hooks", "composition-picker"])( +it.each(["market", "plugins", "mcp", "skills", "composition-picker"])( "finds the unmounted %s block by its displayed English title and lands on it", async (anchor) => { language("en");