mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): settings say where they are and what changing them costs
Two questions the settings screen could not answer about itself. Finding a setting meant already knowing which of fourteen pages it was filed under. And what a change would do was one sentence at the top of the whole sheet — "takes effect immediately; things that rebuild the runtime cannot be changed while a turn is running" — which speaks for two of the three answers and leaves the reader to work out which one they are holding. Both are now facts in a table, not something to work out. The search corpus is that table and never the page. Sections mount one at a time, so reading the DOM could only ever search the page already open, and mounting the rest to search them is how a page nobody asked for starts making requests. Typing 代理 finds the network page without opening it, and the guard measures exactly that: the block is absent from the document and the port is asked nothing it was not already asked. Keywords buy a way in and nothing else. 代理 is not what that setting is called, the result says the setting's own name, and no alias may be spelled like some other setting's title — the same line R3 drew between what makes something discoverable and what makes it what it is. When a change lands is declared per block, from what the endpoint behind it really does. Every value here was read off whether that handler reaches the kernel's rebuild, not off a hint's wording or a handler's name: model, effort, roles, permissions, sandbox, shell and the context bounds rebuild; preset, plan mode and tool approval do not, whatever the composer's comment said. Storage and the interface language are the third answer, and that one has to say both halves — saved already, in force after a restart — or closing the sheet reads as losing what was just typed. Group's id is required, so a settings block cannot be written without naming itself, and a name the catalogue does not carry fails. That is the whole of "no setting may be silent about its cost": there is no default to fall back to and nowhere to leave it out. The appearance page builds its nine blocks by hand rather than through Group, so ApplyNote is separate from it and those blocks answer the same question the same way. What counts as a block is having a heading — Storage draws three frames with none, and they belong to the block above them. Three sabotages. Searching only the open page, deleting a catalogue row, and defaulting the cost to "immediate" each turn their own guards red; the last one is checked by two blocks alike in title, shape and children that say different things because the table says different things. The search is a view and stays one. Its results jump through settings.section, the id the nav already uses, because arriving from a search is the same arrival — no second identity for finding a thing. Seal A stays held and Seal B stays at 98. Not done here, and not silently: the preset block's hint still explains that it takes effect immediately, which the label under it now also says. Trimming duplicated copy is worth doing and is not what this change is about.
This commit is contained in:
1 parent
47e518f943
commit
3966791c6f
9 files changed
+579
-56
No files matched your search
@@ -178,6 +178,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "theme.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
|
||||
// Finding a setting changes what is on screen and nothing else: it reaches
|
||||
// no kernel state, and the jump it offers is settings.section, because
|
||||
// arriving from a search is the same arrival as arriving from the nav.
|
||||
{ id: "settings.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.isolate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
// alone — one catalogue is still the model, and this file is read as its
|
||||
// continuation: same keying by Chinese source text, same grouping by screen.
|
||||
export const EN_SETTINGS: Record<string, string> = {
|
||||
// 找设置,以及改它要付什么
|
||||
"搜索设置…": "Search settings…",
|
||||
"搜索设置": "Search settings",
|
||||
"没有匹配的设置": "No matching setting",
|
||||
"立即生效": "In effect now",
|
||||
"重建运行时": "Rebuilds the runtime",
|
||||
"重启后生效": "Takes effect after a restart",
|
||||
"已保存 · 重启后生效": "Saved · takes effect after a restart",
|
||||
|
||||
// ── 设置:分区与导航 ─────────────────────────────────────────────
|
||||
"设置分类": "Settings sections",
|
||||
"这个设置分区出错了": "This settings section failed",
|
||||
|
||||
@@ -2982,6 +2982,56 @@
|
||||
color-mix(in srgb, var(--accent) 34%, transparent) 50%, transparent 96%);
|
||||
}
|
||||
.prefs-hd h2 { margin: 0; flex: none; font: var(--w-semi) 14px var(--ui); color: var(--text); letter-spacing: .01em }
|
||||
/* 找设置的入口,就在标题旁边。原来这个位置挂的是一句对三档里的两档说话的
|
||||
总说明;那句话现在落在每个设置块自己身上(.apply)。 */
|
||||
.prefs-find {
|
||||
flex: 1; min-width: 0; max-width: 300px; margin-left: auto;
|
||||
padding: 5px 10px; border-radius: var(--r-pill);
|
||||
border: 1px solid var(--border); background: var(--overlay);
|
||||
font: var(--w-reg) 12px var(--ui); color: var(--text);
|
||||
transition: border-color .14s, background-color .14s;
|
||||
}
|
||||
.prefs-find::placeholder { color: var(--ghost) }
|
||||
.prefs-find:focus { outline: none; border-color: var(--focus); background: var(--raised) }
|
||||
|
||||
/* 结果压在头栏下面,设置本身不重画 —— 找东西不该把你正在看的那一页收走。 */
|
||||
.prefs-found {
|
||||
position: absolute; z-index: 5; top: 100%; right: 13px; width: min(420px, calc(100% - 26px));
|
||||
max-height: min(52vh, 420px); overflow-y: auto;
|
||||
display: flex; flex-direction: column; padding: 5px;
|
||||
border: 1px solid var(--border); border-radius: 0 0 var(--r-md) var(--r-md);
|
||||
border-top: 0; background: var(--float); box-shadow: var(--shadow-float);
|
||||
animation: card .14s var(--ease-out) both;
|
||||
}
|
||||
.prefs-found button {
|
||||
display: flex; align-items: baseline; gap: 9px; padding: 7px 9px;
|
||||
border: 0; border-radius: var(--r-xs); background: none; text-align: left; cursor: pointer;
|
||||
}
|
||||
.prefs-found button:hover { background: var(--float-hi) }
|
||||
.prefs-found button:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px }
|
||||
.prefs-found .where { flex: none; font: var(--w-reg) 10px var(--mono); letter-spacing: var(--track-cap); text-transform: uppercase; color: var(--faint) }
|
||||
.prefs-found .what { flex: 1; min-width: 0; font: var(--w-med) 12.5px var(--ui); color: var(--text) }
|
||||
.prefs-found .cost { flex: none; font: var(--w-reg) 10.5px var(--mono); color: var(--faint) }
|
||||
.prefs-found .none { margin: 0; padding: 9px; font: var(--w-reg) 12px var(--ui); color: var(--faint) }
|
||||
|
||||
/* 改一下这一项要付什么,是一条事实,不是一级警告:三档一个颜色,只比正文轻。
|
||||
真正该染色的是「任务运行中不能改」那种当下状态,不是这个。 */
|
||||
.apply {
|
||||
margin: 9px 0 0; font: var(--w-reg) 10.5px var(--mono); color: var(--ghost);
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.apply::before {
|
||||
content: ""; width: 4px; height: 4px; border-radius: 50%;
|
||||
background: color-mix(in srgb, currentColor 70%, transparent);
|
||||
}
|
||||
.apply[data-apply="runtime-rebuild"]::before { background: color-mix(in srgb, var(--net) 60%, transparent) }
|
||||
|
||||
/* 搜索落点:就是这里。导航反馈,不是这个设置变了 —— 所以它只闪一下就没。 */
|
||||
.grp[data-landed] { animation: landed .9s var(--ease-out) both }
|
||||
@keyframes landed {
|
||||
0% { background: color-mix(in srgb, var(--accent) 13%, transparent) }
|
||||
100% { background: transparent }
|
||||
}
|
||||
.prefs-hd p {
|
||||
margin: 0; flex: 1; min-width: 0; font: var(--w-reg) 11.5px var(--ui); color: var(--faint);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
@@ -4038,7 +4088,7 @@
|
||||
animation: none !important; animation-delay: 0s !important;
|
||||
}
|
||||
/* 队列一次能进来几十行,逐行入场就是几十次移动。行在不在是状态,动不动不是。 */
|
||||
.queue, .qi, .ovf, .tabconfirm { animation: none !important }
|
||||
.queue, .qi, .ovf, .tabconfirm, .grp[data-landed] { animation: none !important }
|
||||
}
|
||||
|
||||
.ws[data-busy] { opacity: .5; pointer-events: none }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApplyNote } from "./Group";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { TrayPrefs, AgentPort, Appearance as Look, ThemePack } from "../port/port";
|
||||
import { MONO_FAMILIES, UI_FAMILIES, installed, readDefault, readSteps } from "./look";
|
||||
@@ -214,7 +215,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-language" data-setting="language">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("语言")}</h2>
|
||||
</div>
|
||||
@@ -231,10 +232,11 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</div>
|
||||
<p className="note">{t("改语言要重开窗口才生效")}</p>
|
||||
</div>
|
||||
<ApplyNote id="language" />
|
||||
</section>
|
||||
|
||||
{tray && (
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-window" data-setting="window">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("窗口")}</h2>
|
||||
</div>
|
||||
@@ -272,10 +274,11 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ApplyNote id="window" />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-size" data-setting="size">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("大小")}</h2>
|
||||
</div>
|
||||
@@ -317,9 +320,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ApplyNote id="size" />
|
||||
</section>
|
||||
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-font" data-setting="font">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("字体")}</h2>
|
||||
</div>
|
||||
@@ -342,9 +346,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
onPick={(v) => set({ fontMono: v })}
|
||||
/>
|
||||
</div>
|
||||
<ApplyNote id="font" />
|
||||
</section>
|
||||
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-wallpaper" data-setting="wallpaper">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("壁纸")}</h2>
|
||||
{look.wallpaper && (
|
||||
@@ -467,9 +472,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ApplyNote id="wallpaper" />
|
||||
</section>
|
||||
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-weight" data-setting="weight">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("文字粗细")}</h2>
|
||||
</div>
|
||||
@@ -483,9 +489,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ApplyNote id="weight" />
|
||||
</section>
|
||||
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-contrast" data-setting="contrast">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("文字对比度")}</h2>
|
||||
</div>
|
||||
@@ -499,9 +506,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ApplyNote id="contrast" />
|
||||
</section>
|
||||
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-mode" data-setting="mode">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("明暗")}</h2>
|
||||
</div>
|
||||
@@ -515,9 +523,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ApplyNote id="mode" />
|
||||
</section>
|
||||
|
||||
<section className="grp">
|
||||
<section className="grp" id="set-scheme" data-setting="scheme">
|
||||
<div className="grp-hd">
|
||||
<h2>{t("配色")}</h2>
|
||||
<span className="now">{packs.length ? t("{n} 个已装", { n: packs.length }) : ""}</span>
|
||||
@@ -545,6 +554,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
))}
|
||||
{packs.length === 0 && <p className="note">{t("尚未安装配色包。将包含 theme.json 的目录放入 themes/ 后即会显示在此处。")}</p>}
|
||||
</div>
|
||||
<ApplyNote id="scheme" />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { t } from "../i18n";
|
||||
import { SETTING_AT } from "./prefsnav";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// id is required, and that is the whole enforcement: a settings block cannot
|
||||
// be written without naming itself, and a name the catalogue does not carry
|
||||
// fails the gate. So "which setting is this, and when does changing it take
|
||||
// effect" has no default and no way to be left unanswered.
|
||||
export function Group({
|
||||
id, title, hint, now, action, children,
|
||||
}: {
|
||||
id: string; title: string; hint?: string; now?: string; action?: ReactNode; children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="grp" id={`set-${id}`} data-setting={id}>
|
||||
<div className="grp-hd">
|
||||
<h2>{title}</h2>
|
||||
{now && <span className="now">{now}</span>}
|
||||
{action}
|
||||
</div>
|
||||
{hint && <p className="hint">{hint}</p>}
|
||||
{children && <div className="grp-items">{children}</div>}
|
||||
{/* A fact about this block, at the weight of a fact: what it costs to
|
||||
change is not a warning, and three of them on a page should not read
|
||||
as three alarms. */}
|
||||
<ApplyNote id={id} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** What it costs to change what is in this block. Separate from Group so a
|
||||
* block that draws its own frame still cannot go without one — the appearance
|
||||
* page builds its sections by hand, and the question is the same there. */
|
||||
export function ApplyNote({ id }: { id: string }) {
|
||||
const apply = SETTING_AT(id)?.apply;
|
||||
if (!apply || apply === "none") return null;
|
||||
return <p className="apply" data-apply={apply}>{t(APPLY_SAID[apply])}</p>;
|
||||
}
|
||||
|
||||
// 「已保存」和「生效」是两件事,重启那一档必须把它们分开说 —— 否则关掉设置的人
|
||||
// 不知道刚填的东西还在不在。
|
||||
|
||||
// 「已保存」和「生效」是两件事,重启那一档必须把它们分开说 —— 否则关掉设置的人
|
||||
// 不知道刚填的东西还在不在。
|
||||
const APPLY_SAID: Record<"immediate" | "runtime-rebuild" | "restart", string> = {
|
||||
immediate: "立即生效",
|
||||
"runtime-rebuild": "重建运行时",
|
||||
restart: "已保存 · 重启后生效",
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { listenAction } from "./listen";
|
||||
import { useRuntimeReload } from "./RuntimeReload";
|
||||
import type { AccountState, AgentPort, Appearance as Look, ApprovalMode, CapabilityScope, McpEntry, ModelEntry, PluginPackage, Preset, RoleAssignments, SessionStatus, SkillEntry } from "../port/port";
|
||||
import { arrowTabs } from "./tablist";
|
||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||
import { ICON, NAV } from "./prefsnav";
|
||||
import { ICON, NAV, SETTING_AT, SETTINGS } from "./prefsnav";
|
||||
import { Group } from "./Group";
|
||||
import type { Section } from "./prefsnav";
|
||||
import { AddServer } from "./AddServer";
|
||||
import { Remotes } from "./Remotes";
|
||||
@@ -88,6 +89,10 @@ interface Props {
|
||||
|
||||
export function Settings({ hub, onError, port, status, theme, onTheme, contrast, onContrast, weight, onWeight, look, onLook, onClose, onChanged, reloadThemes, at: opened, account: acct, reloadAccount }: Props) {
|
||||
const [at, setAt] = useState<Section>((opened as Section) || "session");
|
||||
// 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到
|
||||
// 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。
|
||||
const [query, setQuery] = useState("");
|
||||
const [landed, setLanded] = useState("");
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [roles, setRoles] = useState<RoleAssignments | null>(null);
|
||||
const [protocol, setProtocol] = useState<Record<string, string>>({});
|
||||
@@ -282,6 +287,34 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
const shown = (id: Section) =>
|
||||
(id !== "remote" || remoteBook !== null) && (id !== "advanced" || ELSEWHERE.length > 0);
|
||||
|
||||
// 搜索命中的是这张表里的条目,包括当前没有挂载的那些分区;能不能显示由 shown
|
||||
// 决定,和它有没有被渲染过无关。
|
||||
const found = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return SETTINGS.filter((e) => shown(e.section) && matches(e, q));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, remoteBook]);
|
||||
// 落点:跳过去之后短暂标一下「就是这里」。这是导航反馈,不是状态变化。
|
||||
const go = (section: Section, anchor: string) => {
|
||||
setAt(section);
|
||||
setQuery("");
|
||||
setLanded(anchor);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!landed) return;
|
||||
const el = root.current?.querySelector<HTMLElement>(`#set-${CSS.escape(landed)}`);
|
||||
el?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
// 「就是这里」是一次导航反馈,不是这个设置变了 —— 所以它只是元素上短暂的
|
||||
// 一个标记,不进 React 状态,也不用往每个块穿一个纯表现的 prop。
|
||||
el?.setAttribute("data-landed", "");
|
||||
const done = setTimeout(() => setLanded(""), 900);
|
||||
return () => {
|
||||
clearTimeout(done);
|
||||
el?.removeAttribute("data-landed");
|
||||
};
|
||||
}, [landed, at]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prefs"
|
||||
@@ -296,10 +329,55 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
<div className="prefs-sheet" role="dialog" aria-modal="true" aria-label={t("设置")}>
|
||||
<div className="prefs-hd">
|
||||
<h2>{t("设置")}</h2>
|
||||
<p>{t("改动立刻生效;需要重建运行时的项目,在任务运行期间无法修改")}</p>
|
||||
{/* 原来这里挂着一句「改动立刻生效;需要重建运行时的……」。它对三档里的
|
||||
两档说了话,而且要读的人自己判断手上这一项属于哪一档 —— 那句话现在
|
||||
落在每一个设置块自己身上。 */}
|
||||
<input
|
||||
className="prefs-find"
|
||||
value={query}
|
||||
// Typing filters and Escape takes the filter back: two events on one
|
||||
// box, both operating the same one thing.
|
||||
data-action-change="settings.search"
|
||||
data-action-keydown="settings.search"
|
||||
placeholder={t("搜索设置…")}
|
||||
aria-label={t("搜索设置")}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 一层一层退:有搜索就先退搜索,没有才退整个设置。
|
||||
if (e.key === "Escape" && query) {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn sm" data-action="settings.close" onClick={onClose}>
|
||||
{t("关闭")} <span className="esc">Esc</span>
|
||||
</button>
|
||||
{found !== null && (
|
||||
<div className="prefs-found" role="listbox" aria-label={t("搜索设置")}>
|
||||
{found.length === 0 ? (
|
||||
<p className="none">{t("没有匹配的设置")}</p>
|
||||
) : (
|
||||
found.map((e) => (
|
||||
<button
|
||||
key={e.anchor}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
data-action="settings.section"
|
||||
data-value={e.section}
|
||||
data-target={e.anchor}
|
||||
onClick={() => go(e.section, e.anchor)}
|
||||
>
|
||||
<span className="where">{t(SECTION_NAME[e.section] ?? e.section)}</span>
|
||||
<span className="what">{t(e.title)}</span>
|
||||
{SETTING_AT(e.anchor)?.apply === "runtime-rebuild" && <span className="cost">{t("重建运行时")}</span>}
|
||||
{SETTING_AT(e.anchor)?.apply === "restart" && <span className="cost">{t("重启后生效")}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfigTrouble port={port} onRepaired={onChanged} />
|
||||
@@ -342,7 +420,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
<div className="prefs-col">
|
||||
{at === "session" && (
|
||||
<>
|
||||
<Group title={t("执行设定")} now={preset} hint={t("决定任务完成的判定标准。切换立刻生效,不会重建运行时。")}>
|
||||
<Group id="preset" title={t("执行设定")} now={preset} hint={t("决定任务完成的判定标准。切换立刻生效,不会重建运行时。")}>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("执行设定")}>
|
||||
{PRESETS.map(([id, name]) => (
|
||||
<button key={id} role="radio" data-action="chrome.preset" data-value={id}
|
||||
@@ -357,7 +435,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
{/* An on/off that reverses by clicking again is a switch, not two
|
||||
options — the same control the recipes and the sandbox's
|
||||
network egress use. */}
|
||||
<Group title={t("计划模式")} hint={t("开启时 agent 无法获得写权限。该限制由工具本身实施,不依赖提示词中的约定。")}>
|
||||
<Group id="plan-mode" title={t("计划模式")} hint={t("开启时 agent 无法获得写权限。该限制由工具本身实施,不依赖提示词中的约定。")}>
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("只读地出计划")}</span>
|
||||
@@ -374,7 +452,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
<Group title={t("这个会话在哪写")}>
|
||||
<Group id="session-dir" title={t("这个会话在哪写")}>
|
||||
<div className="kv">
|
||||
<span className="k">{t("工作目录")}</span>
|
||||
<span className="v">{status?.cwd ?? "—"}</span>
|
||||
@@ -408,7 +486,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
<span className="why">{failed}</span>
|
||||
</div>
|
||||
)}
|
||||
<Group title={t("分工")} now={roles ? t("{n} 个已指派", { n: assigned }) : undefined}
|
||||
<Group id="roles" title={t("分工")} now={roles ? t("{n} 个已指派", { n: assigned }) : undefined}
|
||||
hint={t("每个位置默认使用主模型,只有明确指派过的才会单独设置。更换指派与更换主模型一样需要重建运行时,任务运行期间无法修改。")}>
|
||||
<Roles models={models} roles={roles} main={status?.modelRef} busy={busy}
|
||||
onSet={(role, ref) => run(`role:${role}`, async () => {
|
||||
@@ -416,12 +494,12 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
loadRoles();
|
||||
})} />
|
||||
</Group>
|
||||
<Group title={t("模型")} now={nav.model} hint={t("切换会保留对话并重建运行时,任务运行期间无法切换。标签只显示已探测到的能力;留空表示端点未声明,不代表不支持。")}>
|
||||
<Group id="model" title={t("模型")} now={nav.model} hint={t("切换会保留对话并重建运行时,任务运行期间无法切换。标签只显示已探测到的能力;留空表示端点未声明,不代表不支持。")}>
|
||||
<Models models={models} current={status?.modelRef} busy={busy} protocol={protocol}
|
||||
onPick={(ref) => run(ref, () => port.setModel(ref))} />
|
||||
</Group>
|
||||
{efforts.length > 0 ? (
|
||||
<Group title={t("推理强度")} hint={t("以下档位由当前模型的端点支持,auto 表示使用端点自身的默认值。")}>
|
||||
<Group id="effort" title={t("推理强度")} hint={t("以下档位由当前模型的端点支持,auto 表示使用端点自身的默认值。")}>
|
||||
<div className="seg" role="group" aria-label={t("推理强度")}>
|
||||
{efforts.map((e) => (
|
||||
<button key={e} data-action="reasoning.effort" data-value={e}
|
||||
@@ -433,13 +511,13 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group title={t("推理强度")} hint={t("当前模型未提供可调的推理档位,因此不显示该选项。")} />
|
||||
<Group id="effort" title={t("推理强度")} hint={t("当前模型未提供可调的推理档位,因此不显示该选项。")} />
|
||||
)}
|
||||
<Group title={t("上下文维护")}
|
||||
<Group id="context" title={t("上下文维护")}
|
||||
hint={t("对话长到一定程度会折叠成摘要再继续。折叠点取「窗口比例」与「压缩阈值」中先到的那个。")}>
|
||||
<Compaction port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group
|
||||
<Group id="providers"
|
||||
title={t("连接")}
|
||||
hint={t("模型的来源。添加时只需填写地址和 key;协议、模型列表和图片支持会自动向端点探测,探测不到的才需要手动填写。")}
|
||||
>
|
||||
@@ -452,7 +530,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
|
||||
{at === "tools" && (
|
||||
<>
|
||||
<Group title={t("工具批准")} now={approval} hint={t("这是 agent 访问你的文件前的唯一审批入口,被拦下的操作没有其他途径可以绕过。")}>
|
||||
<Group id="approval" title={t("工具批准")} now={approval} hint={t("这是 agent 访问你的文件前的唯一审批入口,被拦下的操作没有其他途径可以绕过。")}>
|
||||
{/* Four rows of label-and-description was 190px for one choice,
|
||||
and it was the only choice in this pane shaped that way. The
|
||||
description follows the selection instead: what a档 does is
|
||||
@@ -469,19 +547,19 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
</div>
|
||||
<p className="note">{t(APPROVALS.find(([id]) => id === status?.toolApprovalMode)?.[2] ?? "")}</p>
|
||||
</Group>
|
||||
<Group
|
||||
<Group id="rules"
|
||||
title={t("明确的规矩")}
|
||||
hint={t("上一项决定是否向你询问,此处决定哪些操作始终禁止、哪些无需询问。改动会重建运行时,任务运行期间无法修改。")}
|
||||
>
|
||||
<Rules port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group
|
||||
<Group id="sandbox"
|
||||
title={t("沙箱")}
|
||||
hint={t("批准之后可以操作的范围。该限制不依赖 agent 自觉:写入范围由工具实施,命令隔离由操作系统实施。")}
|
||||
>
|
||||
<Sandbox port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group
|
||||
<Group id="shell"
|
||||
title={t("命令交给谁执行")}
|
||||
hint={t("agent 的所有命令都由该程序执行,它也决定命令使用哪种语法;选择错误会导致每条命令都执行失败。下方只列出本机已安装的程序。更换需要重建运行时,任务运行期间无法修改。")}
|
||||
>
|
||||
@@ -491,7 +569,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "hooks" && (
|
||||
<Group
|
||||
<Group id="hooks"
|
||||
title={t("自动化")}
|
||||
hint={t("在 agent 执行任务前后运行你自己的命令。这些命令在本机以你的权限运行;可以拦截 agent 的两个事件已在下方标出。")}
|
||||
>
|
||||
@@ -510,14 +588,14 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
{/* 装完、改完、删完都要过这一步才算数——把它放在包列表上面,
|
||||
因为它管的是整个运行时,不是某一个包。 */}
|
||||
<Group
|
||||
<Group id="ext-runtime"
|
||||
title={t("运行时")}
|
||||
hint={t("修改扩展代码,或安装、删除、启用或停用插件包之后,用它使改动生效。当前这一轮不受影响,下一轮开始使用新的配置。")}
|
||||
action={reload.action}
|
||||
>
|
||||
{reload.note}
|
||||
</Group>
|
||||
<Group
|
||||
<Group id="plugins"
|
||||
title={t("插件包")}
|
||||
now={packages.length ? t("{n} 个", { n: packages.length }) : undefined}
|
||||
hint={t("一个包可以同时提供技能、命令、自动化钩子和外部服务。安装与导入是同一个操作:提供一个仓库地址,或本机的一个文件夹。")}
|
||||
@@ -552,7 +630,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
{/* Below the packages: what was added by hand. A server the user
|
||||
typed in themselves is not part of anyone's package, and
|
||||
filing it under one would misname where it came from. */}
|
||||
<Group
|
||||
<Group id="mcp"
|
||||
title={t("外部工具")}
|
||||
now={looseMcp.length ? t("{n} 个服务", { n: looseMcp.length }) : undefined}
|
||||
hint={t("你自行接入的 MCP 服务。它们提供的能力与内置工具等同,列出的每一项都可以操作你的文件和数据。关闭后会立即从本轮工具列表中移除,重启后保持关闭。")}
|
||||
@@ -577,7 +655,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
))}
|
||||
{looseMcp.length === 0 && !adding && <div className="empty">{t("没有自己接入的外部服务。")}</div>}
|
||||
</Group>
|
||||
<Group
|
||||
<Group id="skills"
|
||||
title={t("技能")}
|
||||
now={looseSkills.length ? t("{on}/{all} 开着", { on: looseOn, all: looseSkills.length }) : undefined}
|
||||
hint={t(
|
||||
@@ -595,7 +673,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "network" && (
|
||||
<Group
|
||||
<Group id="network"
|
||||
title={t("网络")}
|
||||
hint={t("模型请求、MCP 远程服务和网页抓取都经由此处。配置错误通常表现为聊天无响应,建议先测试连接,测试会指出中断的环节。")}
|
||||
>
|
||||
@@ -604,7 +682,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "remote" && (
|
||||
<Group
|
||||
<Group id="remote"
|
||||
title={t("远程")}
|
||||
hint={t("接入另一台机器上的工作区:内核在远程运行,界面仍在本机。远程面板与本地面板并排显示,每处都会标明所在的机器。")}
|
||||
>
|
||||
@@ -613,7 +691,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "account" && (
|
||||
<Group
|
||||
<Group id="account"
|
||||
title={t("账号")}
|
||||
hint={t("Reasonix 本身不需要账号,仅在需要联网的功能中使用:社区发帖、崩溃问题跟进,以及后续的技能发布。")}
|
||||
>
|
||||
@@ -622,7 +700,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "versions" && (
|
||||
<Group
|
||||
<Group id="versions"
|
||||
title={t("版本")}
|
||||
hint={t("当前安装的版本、可用更新,以及出现问题时如何回退。回退后将固定在你选择的版本,不会被自动更新覆盖。")}
|
||||
>
|
||||
@@ -631,7 +709,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "memory" && (
|
||||
<Group
|
||||
<Group id="memory"
|
||||
title={t("记忆")}
|
||||
hint={t("agent 自动记录的内容:你没有配置过,但它会据此执行。此处按触发时机分组,并标出上一轮实际使用的条目。")}
|
||||
>
|
||||
@@ -640,7 +718,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "usage" && (
|
||||
<Group
|
||||
<Group id="usage"
|
||||
title={t("用量与成本")}
|
||||
hint={t("本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。")}
|
||||
>
|
||||
@@ -649,7 +727,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "storage" && (
|
||||
<Group title={t("存储")} hint={t("数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。")}><Storage port={port} /></Group>
|
||||
<Group id="storage" title={t("存储")} hint={t("数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。")}><Storage port={port} /></Group>
|
||||
)}
|
||||
|
||||
{at === "appearance" && (
|
||||
@@ -657,7 +735,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
)}
|
||||
|
||||
{at === "advanced" && (
|
||||
<Group title={t("还不在这一版里")} hint={t("以下项目尚未提供设置界面,此处仅说明它们当前的位置。")}>
|
||||
<Group id="elsewhere" title={t("还不在这一版里")} hint={t("以下项目尚未提供设置界面,此处仅说明它们当前的位置。")}>
|
||||
{ELSEWHERE.map((x) => (
|
||||
<div className="lrow" key={x}>
|
||||
<span className="ds">{x}</span>
|
||||
@@ -675,21 +753,14 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
);
|
||||
}
|
||||
|
||||
function Group({
|
||||
title, hint, now, action, children,
|
||||
}: {
|
||||
title: string; hint?: string; now?: string; action?: React.ReactNode; children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="grp">
|
||||
<div className="grp-hd">
|
||||
<h2>{title}</h2>
|
||||
{now && <span className="now">{now}</span>}
|
||||
{action}
|
||||
</div>
|
||||
{hint && <p className="hint">{hint}</p>}
|
||||
{children && <div className="grp-items">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const SECTION_NAME: Partial<Record<Section, string>> = Object.fromEntries(
|
||||
NAV.flatMap(([, items]) => items),
|
||||
) as Partial<Record<Section, string>>;
|
||||
|
||||
// Title, aliases, and the page it is on. An alias is a way in and nothing
|
||||
// more: it never becomes the setting's name and no judgement reads it.
|
||||
function matches(e: (typeof SETTINGS)[number], q: string): boolean {
|
||||
if (e.title.toLowerCase().includes(q)) return true;
|
||||
if ((SECTION_NAME[e.section] ?? "").toLowerCase().includes(q)) return true;
|
||||
return (e.keywords ?? []).some((k) => k.toLowerCase().includes(q));
|
||||
}
|
||||
@@ -113,3 +113,86 @@ export const NAV: [string, [Section, string][]][] = [
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/** When a change to this setting is in force.
|
||||
*
|
||||
* Three answers, and they are declared rather than derived: nothing here may
|
||||
* be read off a handler's name, a hint's wording or a component's type. Each
|
||||
* value below was taken from what the endpoint behind the control actually
|
||||
* does — whether its handler reaches the kernel's runtime rebuild — not from
|
||||
* what the screen says about itself.
|
||||
*
|
||||
* immediate canonical state changed and the running runtime already
|
||||
* shows it; nothing is reassembled.
|
||||
* runtime-rebuild canonical state changed and this session's runtime is
|
||||
* rebuilt to adopt it. Refused while a turn is running.
|
||||
* restart written and kept now; this process goes on without it and
|
||||
* the next launch starts with it. Saved is not the same fact
|
||||
* as in force, and the row says both.
|
||||
* none nothing here changes anything: the block only reports. */
|
||||
export type ApplySemantics = "immediate" | "runtime-rebuild" | "restart" | "none";
|
||||
|
||||
export interface SettingEntry {
|
||||
/** Which page it is on. */
|
||||
section: Section;
|
||||
/** The id the block renders, and what a search result scrolls to. */
|
||||
anchor: string;
|
||||
title: string;
|
||||
/** Words someone might look for that the title does not contain. These buy
|
||||
* discoverability and nothing else: an alias never becomes the setting's
|
||||
* name, its identity, or anything a judgement is made on. */
|
||||
keywords?: string[];
|
||||
apply: ApplySemantics;
|
||||
}
|
||||
|
||||
// One row per block the settings screen renders, checked both ways against
|
||||
// what it really renders — a block with no row here fails, and a row nothing
|
||||
// renders fails too.
|
||||
export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "session", anchor: "preset", title: "执行设定", apply: "immediate", keywords: ["均衡", "交付", "完成判定"] },
|
||||
{ section: "session", anchor: "plan-mode", title: "计划模式", apply: "immediate", keywords: ["只读", "先规划"] },
|
||||
{ section: "session", anchor: "session-dir", title: "这个会话在哪写", apply: "none", keywords: ["工作目录", "路径"] },
|
||||
|
||||
{ section: "model", anchor: "roles", title: "分工", apply: "runtime-rebuild", keywords: ["子代理", "规划", "执行", "审查"] },
|
||||
{ section: "model", anchor: "model", title: "模型", apply: "runtime-rebuild", keywords: ["切换", "端点"] },
|
||||
{ section: "model", anchor: "effort", title: "推理强度", apply: "runtime-rebuild", keywords: ["思考", "reasoning", "档位"] },
|
||||
{ section: "model", anchor: "context", title: "上下文维护", apply: "runtime-rebuild", keywords: ["上下文窗口", "压缩", "compaction"] },
|
||||
// Adding a source does not rebuild; changing which protocol a source is
|
||||
// reached through switches the model, and that does. The stronger of the two
|
||||
// is what the row promises, because the weaker one would be a promise this
|
||||
// block cannot keep.
|
||||
{ section: "model", anchor: "providers", title: "连接", apply: "runtime-rebuild", keywords: ["提供商", "api key", "密钥", "协议", "地址"] },
|
||||
|
||||
{ section: "tools", anchor: "approval", title: "工具批准", apply: "immediate", keywords: ["权限", "yolo", "询问", "放行"] },
|
||||
{ section: "tools", anchor: "rules", title: "明确的规矩", apply: "runtime-rebuild", keywords: ["permissions", "允许", "拒绝", "配方"] },
|
||||
{ section: "tools", anchor: "sandbox", title: "沙箱", apply: "runtime-rebuild", keywords: ["隔离", "联网", "写权限", "ssh-agent"] },
|
||||
{ section: "tools", anchor: "shell", title: "命令交给谁执行", apply: "runtime-rebuild", keywords: ["bash", "powershell", "解释器"] },
|
||||
|
||||
{ section: "hooks", anchor: "hooks", title: "自动化", apply: "immediate", keywords: ["钩子", "hook", "触发"] },
|
||||
|
||||
{ section: "ext", anchor: "ext-runtime", title: "运行时", apply: "immediate", keywords: ["扩展", "沙盒"] },
|
||||
{ section: "ext", anchor: "plugins", title: "插件包", apply: "immediate", keywords: ["安装", "市场"] },
|
||||
{ section: "ext", anchor: "mcp", title: "外部工具", apply: "immediate", keywords: ["mcp", "服务器", "连接外部"] },
|
||||
{ section: "ext", anchor: "skills", title: "技能", apply: "immediate", keywords: ["skill", "技能包"] },
|
||||
|
||||
{ section: "network", anchor: "network", title: "网络", apply: "immediate", keywords: ["代理", "proxy", "抓取", "超时"] },
|
||||
{ section: "remote", anchor: "remote", title: "远程", apply: "immediate", keywords: ["ssh", "机器", "远端工作区"] },
|
||||
{ section: "account", anchor: "account", title: "账号", apply: "immediate", keywords: ["登录", "社区"] },
|
||||
{ section: "versions", anchor: "versions", title: "版本", apply: "immediate", keywords: ["更新", "升级"] },
|
||||
{ section: "memory", anchor: "memory", title: "记忆", apply: "immediate", keywords: ["记住", "忘记", "事实"] },
|
||||
{ section: "usage", anchor: "usage", title: "用量与成本", apply: "none", keywords: ["token", "花费", "缓存命中"] },
|
||||
{ section: "storage", anchor: "storage", title: "存储", apply: "restart", keywords: ["搬家", "迁移", "磁盘", "位置"] },
|
||||
{ section: "advanced", anchor: "elsewhere", title: "还不在这一版里", apply: "none", keywords: ["配置文件"] },
|
||||
|
||||
{ section: "appearance", anchor: "language", title: "语言", apply: "restart", keywords: ["中文", "english", "界面语言"] },
|
||||
{ section: "appearance", anchor: "window", title: "窗口", apply: "immediate", keywords: ["托盘", "关闭行为"] },
|
||||
{ section: "appearance", anchor: "size", title: "大小", apply: "immediate", keywords: ["缩放", "字号"] },
|
||||
{ section: "appearance", anchor: "font", title: "字体", apply: "immediate", keywords: ["等宽", "mono"] },
|
||||
{ section: "appearance", anchor: "wallpaper", title: "壁纸", apply: "immediate", keywords: ["背景", "图片"] },
|
||||
{ section: "appearance", anchor: "weight", title: "文字粗细", apply: "immediate", keywords: ["加粗", "字重"] },
|
||||
{ section: "appearance", anchor: "contrast", title: "文字对比度", apply: "immediate", keywords: ["柔和", "对比"] },
|
||||
{ section: "appearance", anchor: "mode", title: "明暗", apply: "immediate", keywords: ["深色", "浅色", "跟随系统"] },
|
||||
{ section: "appearance", anchor: "scheme", title: "配色", apply: "immediate", keywords: ["主题", "theme", "色板"] },
|
||||
];
|
||||
|
||||
export const SETTING_AT = (anchor: string) => SETTINGS.find((s) => s.anchor === anchor);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "@babel/parser";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Group } from "./Group";
|
||||
import { SETTINGS } from "./prefsnav";
|
||||
import { walk, type Node } from "./roots";
|
||||
|
||||
// The settings screen has to answer two questions about itself: where a
|
||||
// setting is, and what changing it costs. Both are read from one table, and
|
||||
// this holds that table to what the screen really renders — in both
|
||||
// directions, because a row nothing draws hides the same debt a block with no
|
||||
// row does.
|
||||
const SOURCES = import.meta.glob(["./**/*.tsx", "!./**/*.test.*"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
|
||||
const tree = (src: string) =>
|
||||
parse(src, { sourceType: "module", plugins: ["typescript", "jsx"], errorRecovery: true }) as unknown as Node;
|
||||
|
||||
const attr = (el: Node, name: string): Node | undefined =>
|
||||
((el.attributes as Node[]) ?? []).find((a) => a.type === "JSXAttribute" && ((a.name as Node)?.name as string) === name);
|
||||
|
||||
const literal = (a?: Node): string | null => {
|
||||
const v = a?.value as Node | undefined;
|
||||
return v?.type === "StringLiteral" ? (v.value as string) : null;
|
||||
};
|
||||
|
||||
const name = (el: Node): string => {
|
||||
const n = el.name as Node;
|
||||
return n?.type === "JSXIdentifier" ? (n.name as string) : "";
|
||||
};
|
||||
|
||||
/** Every settings block the tree renders, by the id it names itself with.
|
||||
* A hand-drawn frame counts as a settings block when it carries an h2 — that
|
||||
* is the heading Group itself renders, and what tells a block apart from the
|
||||
* sub-labels inside one (Storage draws three of those with h3). */
|
||||
function rendered(): { ids: string[]; unnamed: string[] } {
|
||||
const ids: string[] = [];
|
||||
const unnamed: string[] = [];
|
||||
for (const [path, src] of Object.entries(SOURCES)) {
|
||||
walk(tree(src), (n) => {
|
||||
if (n.type === "JSXOpeningElement" && name(n) === "Group") {
|
||||
const id = literal(attr(n, "id"));
|
||||
if (id) ids.push(id);
|
||||
else unnamed.push(`${path}:${n.loc?.start.line} <Group> with no literal id`);
|
||||
return;
|
||||
}
|
||||
if (n.type !== "JSXElement") return;
|
||||
const open = n.openingElement as Node;
|
||||
if (literal(attr(open, "className")) !== "grp") return;
|
||||
let titled = false;
|
||||
walk(n, (d) => {
|
||||
if (d.type === "JSXOpeningElement" && name(d) === "h2") titled = true;
|
||||
});
|
||||
if (!titled) return;
|
||||
const said = attr(open, "data-setting");
|
||||
const id = literal(said);
|
||||
if (id) ids.push(id);
|
||||
// Group names itself from its prop, so the attribute is an expression
|
||||
// there and the ids come from its call sites. Absent is the failure.
|
||||
else if (!said) unnamed.push(`${path}:${open.loc?.start.line} a titled block that names no id`);
|
||||
});
|
||||
}
|
||||
return { ids, unnamed };
|
||||
}
|
||||
|
||||
describe("the settings catalogue", () => {
|
||||
const seen = rendered();
|
||||
|
||||
it("carries every block the screen renders", () => {
|
||||
const known = new Set(SETTINGS.map((e) => e.anchor));
|
||||
const missing = [...new Set(seen.ids)].filter((id) => !known.has(id));
|
||||
expect(missing, "rendered with no row in SETTINGS — where is it, and what does changing it cost?").toEqual([]);
|
||||
});
|
||||
|
||||
it("renders every block it carries", () => {
|
||||
const drawn = new Set(seen.ids);
|
||||
const stale = SETTINGS.map((e) => e.anchor).filter((a) => !drawn.has(a));
|
||||
expect(stale, "a row nothing renders: delete it, or the debt hides here").toEqual([]);
|
||||
});
|
||||
|
||||
// Fail closed. A block with a heading is a setting, and a setting that does
|
||||
// not name itself has no apply semantics and cannot be found by search.
|
||||
it("leaves no titled block unnamed", () => {
|
||||
expect(seen.unnamed, "a titled settings block that names no id").toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every anchor one row", () => {
|
||||
// The screen may draw one anchor from two mutually exclusive branches —
|
||||
// the effort ladder has a form for an endpoint that declares no levels —
|
||||
// so it is the table that has to be unique, not the render sites.
|
||||
expect(SETTINGS.length).toBe(new Set(SETTINGS.map((e) => e.anchor)).size);
|
||||
});
|
||||
|
||||
// Keywords buy a way in. They are not the setting's name, and nothing may
|
||||
// read them as one: a search alias that shadowed a title would make two
|
||||
// settings answer to one word and neither of them be the one meant.
|
||||
it("keeps aliases out of the name space", () => {
|
||||
const titles = new Set(SETTINGS.map((e) => e.title));
|
||||
const clashing = SETTINGS.flatMap((e) => (e.keywords ?? []).filter((k) => titles.has(k)).map((k) => `${e.anchor}: ${k}`));
|
||||
expect(clashing, "an alias spelled exactly like some setting's title").toEqual([]);
|
||||
});
|
||||
|
||||
// Two blocks alike in every way a reader or a compiler can see — same title,
|
||||
// same shape, same absence of children — and they say different things
|
||||
// because the table says different things. Nothing here is inferred from the
|
||||
// wording, the handler or the component: change the declaration and the row
|
||||
// changes, change anything else and it does not.
|
||||
it("reads what a change costs from the declaration and nowhere else", () => {
|
||||
const now = renderToStaticMarkup(<Group id="preset" title="X" />);
|
||||
const later = renderToStaticMarkup(<Group id="storage" title="X" />);
|
||||
expect(now).toContain("立即生效");
|
||||
expect(later).toContain("已保存 · 重启后生效");
|
||||
expect(now).not.toContain("重启");
|
||||
});
|
||||
|
||||
// Saved and in force are two facts, and the restart answer is the one where
|
||||
// they come apart. A row that only said "after a restart" would leave the
|
||||
// reader wondering whether closing this sheet loses what they just typed.
|
||||
it("says a restart setting is already kept", () => {
|
||||
expect(renderToStaticMarkup(<Group id="storage" title="X" />)).toContain("已保存");
|
||||
});
|
||||
|
||||
// Reporting blocks have nothing to apply, and must not claim otherwise.
|
||||
it("promises nothing for a block that changes nothing", () => {
|
||||
const html = renderToStaticMarkup(<Group id="usage" title="X" />);
|
||||
expect(html).not.toContain("生效");
|
||||
expect(html).not.toContain("重建");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Settings } from "./Settings";
|
||||
import type { AgentPort, SessionStatus } from "../port/port";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function draw() {
|
||||
const onClose = vi.fn();
|
||||
// The tree's own mock, not a hand-built stand-in: a fixture that answers
|
||||
// "nothing" in a shape the real screen never sees would be testing itself.
|
||||
const port = new MockPort();
|
||||
const hub = new MockHub();
|
||||
const calls: string[] = [];
|
||||
const watched = new Proxy(port, {
|
||||
get: (own, key: string) => {
|
||||
const v = (own as unknown as Record<string, unknown>)[key];
|
||||
if (typeof v !== "function") return v;
|
||||
return (...a: unknown[]) => {
|
||||
calls.push(key);
|
||||
return (v as (...x: unknown[]) => unknown).apply(own, a);
|
||||
};
|
||||
},
|
||||
}) as unknown as AgentPort;
|
||||
render(
|
||||
<Settings
|
||||
hub={hub as never}
|
||||
port={watched}
|
||||
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
|
||||
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
|
||||
look={{} as never} onLook={() => {}} reloadThemes={() => {}}
|
||||
onClose={onClose} onChanged={() => {}} onError={() => {}}
|
||||
account={null} reloadAccount={() => {}}
|
||||
/>,
|
||||
);
|
||||
const find = () => screen.getByRole("textbox", { name: "搜索设置" });
|
||||
const block = (id: string) => document.querySelector(`[data-setting="${id}"]`);
|
||||
return { calls, onClose, find, block };
|
||||
}
|
||||
|
||||
describe("finding a setting", () => {
|
||||
// The corpus is the table, not the screen. Reading the DOM could only ever
|
||||
// search the page already open, and mounting the others to search them is
|
||||
// how a hidden page starts making requests nobody asked for.
|
||||
it("finds a setting on a page that is not open", async () => {
|
||||
const { find, block } = draw();
|
||||
expect(block("network")).toBeNull();
|
||||
await userEvent.type(find(), "代理");
|
||||
const hit = await screen.findByRole("option", { name: /网络/ });
|
||||
expect(hit.getAttribute("data-value")).toBe("network");
|
||||
expect(hit.getAttribute("data-target")).toBe("network");
|
||||
});
|
||||
|
||||
// The nav's own summary already reads a few of these when the sheet opens,
|
||||
// so "never called" would be a claim about the wrong thing. What searching
|
||||
// must not do is add a read, or put a page on screen to look inside it.
|
||||
it("does not open that page to find it", async () => {
|
||||
const { find, calls, block } = draw();
|
||||
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
|
||||
const before = calls.length;
|
||||
await userEvent.type(find(), "代理");
|
||||
await screen.findByRole("option", { name: /网络/ });
|
||||
expect(block("network")).toBeNull();
|
||||
expect(calls.length).toBe(before);
|
||||
});
|
||||
|
||||
it("goes to the block, not just the page", async () => {
|
||||
const { find, block } = draw();
|
||||
await userEvent.type(find(), "代理");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /网络/ }));
|
||||
await waitFor(() => expect(block("network")).toBeTruthy());
|
||||
expect((find() as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
// An alias is a way in. "代理" is not what this setting is called, and the
|
||||
// result says the setting's own name — otherwise the word someone guessed
|
||||
// would start standing in for what the thing is.
|
||||
it("answers an alias with the setting's own name", async () => {
|
||||
const { find } = draw();
|
||||
await userEvent.type(find(), "代理");
|
||||
const hit = await screen.findByRole("option", { name: /网络/ });
|
||||
expect(hit.textContent).toContain("网络");
|
||||
expect(hit.textContent).not.toContain("代理");
|
||||
});
|
||||
|
||||
it("leaves the page alone when the query is cleared", async () => {
|
||||
const { find, block } = draw();
|
||||
await userEvent.type(find(), "代理");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /网络/ }));
|
||||
await waitFor(() => expect(block("network")).toBeTruthy());
|
||||
await userEvent.type(find(), "沙箱");
|
||||
await userEvent.clear(find());
|
||||
expect(block("network")).toBeTruthy();
|
||||
});
|
||||
|
||||
// One Escape, one layer.
|
||||
it("takes the search back before it takes the settings", async () => {
|
||||
const { find, onClose } = draw();
|
||||
await userEvent.type(find(), "代理");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect((find() as HTMLInputElement).value).toBe("");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so rather than showing nothing", async () => {
|
||||
const { find } = draw();
|
||||
await userEvent.type(find(), "zzzz");
|
||||
expect(await screen.findByText("没有匹配的设置")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user