feat(studio): hooks and scheduled runs move from the extensions page into an Automation card on the Agent behaviour page, as the new Set-automation board draws it

This commit is contained in:
yhh committed 2026-11-06 02:28:06 -08:00
1 parent ab6052b8a2
commit 5868b02bdf
7 files changed
+56 -23

No files matched your search

@@ -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" },
@@ -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<HTMLElement>(".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 只读地跑一遍并留下报告");
});
+28 -1
View File
@@ -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 (
<>
<SectionCard title={t("settings.persona_and_presets")}>
@@ -51,6 +72,12 @@ export function AgentSettings({ port, hub, persona, onChanged }: Props) {
<SubagentClaims port={port} onChanged={onChanged} />
<PeersSection port={port} hub={hub} />
</SectionCard>
<SectionCard title={t("settings.automation")}>
<Group id="hooks" title={t("settings.hooks")} hint={hooksHint} pill={t("settings.manage_storage")}>
<Hooks port={port} onChanged={onChanged} />
</Group>
<ScheduledRuns port={port} hint={runsHint} pill={t("settings.manage_storage")} />
</SectionCard>
</>
);
}
@@ -28,7 +28,7 @@ const SKIP: Record<string, string> = { 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<ScheduleBoard | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -76,7 +76,7 @@ export function ScheduledRuns({ port }: { port: AgentPort }) {
);
if (!board) {
return unreadable ? (
<Group id="scheduled-runs" title={t("panels.scheduled_runs")} hint={t("panels.runs_tasks_read_only_at")}>
<Group id="scheduled-runs" title={t("panels.scheduled_runs")} hint={hint ?? t("panels.runs_tasks_read_only_at")} pill={pill}>
<div className="box">{unreadable}</div>
</Group>
) : null;
@@ -87,7 +87,7 @@ export function ScheduledRuns({ port }: { port: AgentPort }) {
const draft = (patch: Partial<ScheduleDraft>) => setForm((f) => (f ? { ...f, ...patch } : f));
return (
<Group id="scheduled-runs" title={t("panels.scheduled_runs")} hint={t("panels.runs_tasks_read_only_at")}>
<Group id="scheduled-runs" title={t("panels.scheduled_runs")} hint={hint ?? t("panels.runs_tasks_read_only_at")} pill={pill}>
<div className="box">
{unreadable}
<div className="lrow">
-13
View File
@@ -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" && (
<>
<Group id="hooks"
title={t("settings.automation")}
hint={t("settings.run_your_own_commands_before")}
>
<Hooks port={port} onChanged={afterExtChange} />
</Group>
<ScheduledRuns port={port} />
</>
)}
{at === "network" && (
<ConnectSettings port={port} networkPort={networkPort ?? port} host={networkHost ?? ""} hub={hub} onError={onError} remote={shown("remote")} />
)}
+2 -2
View File
@@ -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: ["安装"] },
@@ -44,7 +44,7 @@ async function draw() {
return { calls, onClose, find: () => screen.getByRole<HTMLInputElement>("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");