feat(desktop): the permissions settings page is the board's three cards of short rows

Set-safety draws approval (tool approval, rules, outside text, tools), sandbox
and execution, and web as one row each: the controls that are a value stay on
the row, the ones that are an editor sit behind a pill with their count. The
outside-text switch keeps what it covers and what it does not as the row's hover
text, so the board's one line does not drop it. Message keys the old blocks
alone used are removed.
This commit is contained in:
esengine committed 2026-11-05 04:36:53 +00:00
1 parent 2221374763
commit c4e07cbe0c
16 files changed
+174 -150

No files matched your search

+13 -2
View File
@@ -16,7 +16,6 @@ export const SAFETY: Messages = {
"safety.an_operation_that_declares_no": { zh: "没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)不再占住整个工作区,所以同一个项目里的多个会话可以同时工作,不必互相等待。代价:声明了路径的写入(如编辑文件)仍按路径互斥(落在工作区之外的路径只和同一路径的写入互斥),但 bash 和 MCP 的写入没有事前的路径围栏,只在事后观察,两个会话用它们改同一个文件时可能互相覆盖。默认开启;关闭后为严格模式,没声明范围的操作会占住整个工作区。只对之后新开的会话生效。", en: "An operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) no longer holds the whole workspace, so several sessions in one project can work at the same time without waiting. The cost: writes that declare a path (such as editing a file) still exclude each other by path (a path outside the workspace only excludes writes to that same path), but bash and MCP writes have no path fence beforehand and are only observed afterwards, so two sessions using them on one file can overwrite each other. On by default; turning it off gives strict mode, where an operation with no declared scope holds the whole workspace. Applies only to sessions opened afterwards." },
"safety.approvals_decide_when_to_ask": { zh: "审批决定何时询问;沙盒限定实际访问范围。", en: "Approvals decide when to ask; the sandbox limits actual access." },
"safety.ask": { zh: "询问", en: "Ask" },
"safety.ask_about_text_from_outside": { zh: "询问来自外部的文字", en: "Ask about text from outside" },
"safety.ask_for_confirmation": { zh: "请求确认", en: "Ask for confirmation" },
"safety.asks_before_every_action": { zh: "每次执行操作前请求确认", en: "Asks before every action" },
"safety.auto_approve": { zh: "自动批准", en: "Auto approve" },
@@ -118,7 +117,6 @@ export const SAFETY: Messages = {
"safety.wait_time": { zh: "等待时长", en: "Wait time" },
"safety.what_escape_does_while_a": { zh: "任务运行时按 Esc 的处理方式。点击停止按钮始终立即停止。", en: "What Escape does while a task is running. The stop button always stops at once." },
"safety.what_happens_on_rule": { zh: "{rule} 的处理方式", en: "What happens on {rule}" },
"safety.when_a_call_copies_text": { zh: "调用逐字复制了网页或工具结果里的文字时,Auto 和 YOLO 也先问你。只认用户配置;已授权的 MCP 服务器、改写过或编码过的文字不在其内。无人可问时(无头 YOLO),这类调用会被拒绝而不是执行。修改会重建运行时,任务运行期间无法变更。默认关闭。", en: "When a call copies text word for word from a web page or a tool result, Auto and YOLO ask you first. Only your user config counts; servers you have authorised, and text that was reworded or encoded, are not covered. When nobody can be asked (headless YOLO) such a call is refused instead of run. Changing it rebuilds the runtime and cannot be done while a task runs. Off by default." },
"safety.where_writes_may_land": { zh: "可写入范围", en: "Where writes may land" },
"safety.which_mode_a_new_chat": { zh: "新对话一开始用哪种模式", en: "Which mode a new chat starts in" },
"safety.while_another_session_holds_a": { zh: "另一个会话占着要写的路径时,这里的写入会一直等;设了分钟数,超过就失败,并给模型一个 workspace.claim_timeout 的明确原因。默认 0,一直等到本轮结束;想设上限,建议 120。之后新开的会话才采用新值。", en: "While another session holds a path this write needs, the write waits. With a number of minutes set, it fails past that and the model is told why with workspace.claim_timeout. Default 0 waits as long as the turn lasts; 120 is a sensible limit. Only sessions opened afterwards use it." },
@@ -126,4 +124,17 @@ export const SAFETY: Messages = {
"safety.with_this_off_installing_dependencies": { zh: "关闭后安装依赖、拉取仓库等操作都会失败,这正是该选项的用途", en: "With this off, installing dependencies and cloning repositories will fail — which is what it is for" },
"safety.workspace_root": { zh: "工作区根目录", en: "Workspace root" },
"safety.writing_files_every_write_tool": { zh: "写文件(全部写入工具)", en: "writing files (every write tool)" },
"safety.ask_before_writing_or_running": { zh: "写文件、跑命令前要不要先问", en: "Whether to ask before writing files or running commands" },
"safety.allow_and_deny_by_command": { zh: "按命令、路径写死的允许和拒绝;拒绝规则永远优先", en: "Allow and deny by command or path; a deny rule always wins" },
"safety.edit_rules_n": { zh: "编辑规则({n} 条)", en: "Edit rules ({n})" },
"safety.ask_before_copied_text": { zh: "照抄网页或工具结果里的文字去调用工具前先问你", en: "Ask before a call that copies text from a web page or tool result" },
"safety.which_tools_the_model_sees": { zh: "模型能看到哪些工具", en: "Which tools the model can see" },
"safety.view_and_adjust_n": { zh: "查看与调整({n})", en: "View and adjust ({n})" },
"safety.commands_write_only_here": { zh: "命令只能写工作区、临时目录和工具缓存", en: "Commands can only write to the workspace, temp folders and tool caches" },
"safety.which_program_runs_a_command": { zh: "跑命令用哪个程序", en: "Which program runs a command" },
"safety.when_two_sessions_edit_one": { zh: "两个会话同时改一个项目时怎么处理", en: "What happens when two sessions edit one project" },
"safety.web": { zh: "联网", en: "Web" },
"safety.search_and_fetch_when_needed": { zh: "需要时搜索网页、抓取页面内容", en: "Search the web and fetch pages when needed" },
"safety.details": { zh: "详细设置", en: "Details" },
"safety.what_outside_text_covers": { zh: "调用逐字复制了网页或工具结果里的文字时,Auto 和 YOLO 也先问你。只认用户配置;已授权的 MCP 服务器、改写过或编码过的文字不在其内。无人可问时(无头 YOLO),这类调用会被拒绝而不是执行。修改会重建运行时,任务运行期间无法变更。默认关闭。", en: "When a call copies text word for word from a web page or a tool result, Auto and YOLO ask you first. Only your user config counts; servers you have authorised, and text that was reworded or encoded, are not covered. When nobody can be asked (headless YOLO) such a call is refused instead of run. Changing it rebuilds the runtime and cannot be done while a task runs. Off by default." },
};
@@ -77,8 +77,7 @@ export const SESSION: Messages = {
"session.hide_details": { zh: "收起详情", en: "Hide details" },
"session.hit_hit_miss": { zh: "命中 ÷ (命中 + 未命中)", en: "hit ÷ (hit + miss)" },
"session.hit_hit_miss_leaves_out": { zh: "命中 ÷ (命中 + 未命中);不含服务商未区分的 {n} tokens", en: "hit ÷ (hit + miss); leaves out {n} tokens the provider did not split" },
"session.how_a_background_sub_agent": { zh: "后台子代理写文件时如何占用工作区。默认开启乐观模式:没有声明 write_paths 的子代理只占用正在写的路径;关闭后为严格模式,它占住整个工作区,其他写入要等它结束。修改会重建运行时,任务运行期间无法变更。", en: "How a background sub-agent claims the workspace while it writes. Optimistic mode is on by default: a sub-agent without write_paths holds only the path it is writing. Turn it off for strict mode, where it holds the whole workspace and other writes wait. Changing it rebuilds the runtime, so it cannot change while a task runs." },
"session.how_sessions_in_one_project": { zh: "多个会话在同一个项目里工作时如何互相等待。默认开启乐观模式:没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)不占住整个工作区,多个会话可以同时工作;关闭后为严格模式,它们会占住整个工作区,其他会话要等它结束。只影响之后新开的会话,已在运行的会话保持它启动时的设置。", en: "How sessions in one project wait on each other. Optimistic mode is on by default: an operation that declares no write scope (bash, MCP, a sub-agent without write_paths) does not hold the whole workspace, so several sessions can work at once. Turn it off for strict mode, where such an operation holds the whole workspace and other sessions wait for it. Only sessions opened afterwards pick this up; a running session keeps what it started with." },
"session.each_sub_agents_write_scope": { zh: "子代理同时改文件时各自能写的范围", en: "What each sub-agent may write when several change files at once" },
"session.in_a_out_b": { zh: "输入 {a} · 输出 {b}", en: "In {a} · out {b}" },
"session.keep_it_short_and_recognizable": { zh: "保持简短且易于识别", en: "Keep it short and recognizable" },
"session.live_of_them_are_running": { zh: "其中 {live} 个对话正在运行,停止后才能移除", en: "{live} of them are running; stop them before removing" },
@@ -27,7 +27,6 @@ export const SETTINGS: Messages = {
"settings.brief": { zh: "简短", en: "Brief" },
"settings.cannot_reach_the_identity_service": { zh: "无法连接身份服务", en: "Cannot reach the identity service" },
"settings.checking_sign_in_status": { zh: "正在检查登录状态…", en: "Checking sign-in status…" },
"settings.choose_a_model_and_reasoning": { zh: "按用途选择模型和思考强度;自动使用模型的默认档位。未选择命名模型时显示消息预览。切换会保留对话。", en: "Choose a model and reasoning effort per use; Auto uses the model default. Without a naming model, show a message preview. Switching preserves the conversation." },
"settings.choose_the_default_for_messages": { zh: "选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。", en: "Choose the default for messages sent during a run. Saved on this device only; messages already queued are unchanged." },
"settings.clear_shortcut": { zh: "清除快捷键", en: "Clear shortcut" },
"settings.clears_the_local_credential_only": { zh: "只清除本地登录凭证,不影响会话、记忆和配置", en: "Clears the local credential only; sessions, memory and config are untouched" },
@@ -36,7 +35,6 @@ export const SETTINGS: Messages = {
"settings.closing_the_window_does_not": { zh: "关闭窗口不会中断会话和后台任务;从托盘菜单退出才会完全关闭程序", en: "Closing the window does not stop sessions or background tasks; use Quit in the tray menu to exit completely" },
"settings.collaboration": { zh: "协作", en: "Collaboration" },
"settings.common": { zh: "常用", en: "Common" },
"settings.compacts_automatically_from_model_capacity": { zh: "默认按模型容量自动整理。自定义中转站若无法提供最大上下文,先按 160k 计算;你可以在这里填写实际容量。", en: "Compacts automatically from model capacity. Custom gateways without a maximum context value start at 160k; enter the actual capacity here." },
"settings.completion_standard": { zh: "完成标准", en: "Completion standard" },
"settings.confirm_sign_out": { zh: "确认退出", en: "Confirm sign-out" },
"settings.connect_a_workspace_on_another": { zh: "接入另一台机器上的工作区:内核在远程运行,界面仍在本机。远程面板与本地面板并排显示,每处都会标明所在的机器。", en: "Connect a workspace on another machine: the kernel runs there, the window stays here. Remote panes sit alongside local ones, so each place states which machine it belongs to." },
@@ -57,7 +55,6 @@ export const SETTINGS: Messages = {
"settings.english": { zh: "英文", en: "English" },
"settings.enlarge_the_settings_window": { zh: "放大设置窗口", en: "Enlarge the settings window" },
"settings.environment_variables": { zh: "环境变量", en: "Environment variables" },
"settings.every_command_the_agent_runs": { zh: "agent 的所有命令都由该程序执行,它也决定命令使用哪种语法;选择错误会导致每条命令都执行失败。下方只列出本机已安装的程序。更换需要重建运行时,任务运行期间无法修改。", en: "Every command the agent runs goes through this program, which also decides the syntax those commands must use; the wrong choice makes every command fail rather than run slowly. Only programs installed on this machine are listed. Changing it rebuilds the runtime and cannot be done while a task is running." },
"settings.everyday_questions_stay_lightweight_file": { zh: "日常问答保持轻量;涉及文件修改、代码执行或明确交付物时自动增加验证与复核。", en: "Everyday questions stay lightweight; file changes, code execution, and explicit deliverables automatically receive more verification and review." },
"settings.external_tools": { zh: "外部工具", en: "External tools" },
"settings.file_changes": { zh: "文件改动", en: "File changes" },
@@ -70,7 +67,6 @@ 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.how_far_an_approved_action": { zh: "批准之后可以操作的范围。该限制不依赖 agent 自觉:写入范围由工具实施,命令隔离由操作系统实施。", en: "How far an approved action may reach. This layer does not rely on the agent's cooperation: the write scope is enforced by the tools and command isolation by the operating system." },
"settings.how_it_thinks_how_it": { zh: "它怎么想、怎么问你、怎么协作", en: "How it thinks, how it asks you, how it works with others" },
"settings.how_much_the_model_says": { zh: "模型在两批工具调用之间说多少。简短是现在的样子;详细会让它每批用三四行说清这一批拿到了什么、下一步打算做什么,方便你跟着它的思路,每轮会多几十个输出词。从下一轮开始生效。", en: "How much the model says between batches of tool calls. Brief is how it works now; detailed asks it to write three or four lines per batch on what came back and what it will do next, so you can follow its thinking, at the cost of a few dozen extra output tokens per turn. Takes effect from the next turn." },
"settings.how_plans_are_approved_how": { zh: "计划怎么批、提问等多久、什么算完成", en: "How plans are approved, how long questions wait, what counts as done" },
@@ -136,7 +132,6 @@ export const SETTINGS: Messages = {
"settings.palette": { zh: "配色", en: "Palette" },
"settings.permissions_and_safety": { zh: "权限与安全", en: "Permissions and safety" },
"settings.persona": { zh: "人设", en: "Persona" },
"settings.pick_a_service_to_see": { zh: "选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。", en: "Pick a service to see and change it; the address, key and model list take effect when saved. A custom gateway's protocol and models are detected once it connects." },
"settings.plan_approval": { zh: "计划批准", en: "Plan approval" },
"settings.plan_mode": { zh: "计划模式", en: "Plan mode" },
"settings.plans_and_questions": { zh: "计划与提问", en: "Plans and questions" },
@@ -162,7 +157,6 @@ export const SETTINGS: Messages = {
"settings.sandbox": { zh: "沙箱", en: "Sandbox" },
"settings.sandbox_and_execution": { zh: "沙箱与执行", en: "Sandbox and execution" },
"settings.search_conversations": { zh: "搜索会话", en: "Search conversations" },
"settings.search_page_fetching_and_the": { zh: "搜索、抓网页、内置浏览器", en: "Search, page fetching and the built-in browser" },
"settings.search_settings": { zh: "搜索设置", en: "Search settings" },
"settings.send_after_the_whole_run": { zh: "整轮结束后发送", en: "Send after the whole run" },
"settings.send_or_a_new_line": { zh: "发送,或用 Shift 换行", en: "Send, or a new line with Shift" },
@@ -192,17 +186,13 @@ export const SETTINGS: Messages = {
"settings.tabs_with_a_live_connection": { zh: "实时连接的标签数", en: "Tabs with a live connection" },
"settings.takes_effect_after_a_restart": { zh: "重启后生效", en: "Takes effect after a restart" },
"settings.text": { zh: "中文", en: "中文" },
"settings.text_in_web_pages_and": { zh: "网页和工具结果里的文字可能是别人写给 agent 的指令。开启后,照抄这些文字的调用先问你。", en: "Text in web pages and tool results can be instructions someone wrote for the agent. When on, a call that copies that text asks you first." },
"settings.the_answer_is_written_or": { zh: "回答写完、或者这一轮失败了", en: "The answer is written, or the turn failed" },
"settings.the_few_settings_changed_most": { zh: "最常改的几项放这里,打开设置先看到它们 · 这里是常用项的快捷入口,每项的完整位置在各自分类里", en: "The few settings changed most often, first in view when settings opens · a shortcut here; each one also lives in its own category" },
"settings.the_items_below_have_no": { zh: "以下项目尚未提供设置界面,此处仅说明它们当前的位置。", en: "The items below have no settings screen yet; this only states where they currently live." },
"settings.the_os_could_not_register": { zh: "系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。", en: "The OS could not register this shortcut: it may be occupied, denied permission, or unsupported by this desktop." },
"settings.the_previous_sign_in_has": { zh: "上次的登录已过期。", en: "The previous sign-in has expired." },
"settings.the_setting_above_decides_whether": { zh: "上一项决定是否请求确认,此处决定哪些操作始终禁止、哪些无需确认。修改会重建运行时,任务运行期间无法变更。", en: "The setting above decides whether you are asked; this one decides what is never allowed and what never needs asking. Changes rebuild the runtime and cannot be made while a task is running." },
"settings.the_shortcuts_to_pages_in": { zh: "图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。", en: "The shortcuts to pages in the icon rail and the sidebar: hide any of them or change their order. Sessions, feedback, account and settings are always kept." },
"settings.the_tools_the_agent_uses": { zh: "agent 用来打开和操作网页的工具。该开关单独保存,不受命令行版本浏览器设置的影响。修改会重建运行时,任务运行期间无法变更。", en: "The tools the agent uses to open and operate web pages. This switch is stored on its own and is not affected by the command-line version's browser setting. Changing it rebuilds the runtime and cannot be done while a task is running." },
"settings.these_keys_work_inside_the": { zh: "下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。", en: "These keys work inside the window; ⌘ means Ctrl on Windows and Linux. Configure global show / hide separately above, in the desktop shell only." },
"settings.this_is_the_only_approval": { zh: "这是 agent 访问文件前的唯一审批入口,被拦截的操作没有其他途径可以绕过。", en: "This is the only approval step between the agent and your files. What it stops has no second route around it." },
"settings.this_model": { zh: "当前模型", en: "This model" },
"settings.this_screen_is_fetched_on": { zh: "这一页是单独取的,这次没取到", en: "This screen is fetched on its own, and this time it did not arrive" },
"settings.this_settings_section_failed": { zh: "这个设置分区出错了", en: "This settings section failed" },
@@ -215,16 +205,13 @@ export const SETTINGS: Messages = {
"settings.changes_apply_from_the_next_turn": { zh: "启用、停用、安装或更新后,从下一轮开始生效;正在跑的这一轮不受影响。改了扩展自己的代码,用「重载运行时」重新读取。", en: "Enabling, disabling, installing or updating takes effect from the next turn; the one running is unaffected. After editing an extension's own code, use Reload runtime to read it again." },
"settings.used_for_posting_to_the": { zh: "用于社区发帖与崩溃问题跟进。与模型 API key 无关 —— 登录不会上传你的对话、代码或密钥。", en: "Used for posting to the community and following up crashes. Unrelated to your model API keys — signing in uploads none of your conversations, code or secrets." },
"settings.warns_when_the_drive_holding": { zh: "项目或会话所在的磁盘剩余空间很少时提醒(不足 5 GB 或不足 3%),同一块盘长时间不重复提醒;只提醒,不会清理任何文件", en: "Warns when the drive holding your project or sessions runs short (under 5 GB or 3%), and does not repeat for the same drive for hours. It only warns; it never deletes anything." },
"settings.web_and_browser": { zh: "联网与浏览器", en: "Web and browser" },
"settings.what_it_can_touch_approval": { zh: "它能碰什么:批准、规则、沙箱、联网", en: "What it can touch: approval, rules, sandbox, network" },
"settings.what_the_conversation_shows_and": { zh: "对话里显示什么、怎么折叠", en: "What the conversation shows and how it folds" },
"settings.when_a_session_with_unread": { zh: "打开有未读回答的会话时,在回答开始的位置画一条分隔线。只在这个窗口里记着,重启后不再显示。", en: "When a session with unread answers opens, draw a line where they begin. Kept in this window only; gone after a restart." },
"settings.when_a_turn_ends_tool": { zh: "一轮结束后,把工具调用、思考和中间的文字收成一行,只留最后一句话;点那一行可以展开。运行中的一轮始终展开。", en: "When a turn ends, tool calls, thinking and the sentences in between are gathered into one line and only the last sentence stays out; click the line to open it. The turn that is running stays open." },
"settings.when_disk_space_is_low": { zh: "磁盘空间不足时", en: "When disk space is low" },
"settings.when_feedback_gets_a_reply": { zh: "反馈有新回复时", en: "When feedback gets a reply" },
"settings.when_it_stops_to_ask": { zh: "什么时候停下来问你", en: "When it stops to ask you" },
"settings.when_the_turn_ends": { zh: "这一轮结束时", en: "When the turn ends" },
"settings.where_its_commands_can_write": { zh: "它的命令能写到哪里、能不能联网", en: "Where its commands can write, and whether they can go online" },
"settings.which_service_to_use_which": { zh: "接哪个服务、每种用途用哪个模型、上下文怎么维护", en: "Which service to use, which model for each purpose, how context is kept" },
"settings.while_a_session_is_running": { zh: "有会话正在运行时,电脑不会自动进入睡眠(电池供电时也一样);屏幕仍按系统设置熄灭,全部空闲后恢复。合上笔记本盖子不受此项控制", en: "While a session is running the computer will not go to sleep on its own, on battery power too; the screen still turns off as the system says, and normal sleep resumes once everything is idle. Closing a laptop lid is not controlled by this" },
"settings.while_this_is_on_the": { zh: "开启时 agent 无法获得写权限。该限制由工具本身实施,不依赖提示词中的约定。", en: "While this is on, the agent cannot obtain write access. The limit is enforced by the tools themselves, not by an instruction in the prompt." },
@@ -128,7 +128,7 @@ export const WORKBENCH: Messages = {
"workbench.still_searching_some_historical_conversations": { zh: "仍在搜索部分历史会话…", en: "Still searching some historical conversations…" },
"workbench.terminal": { zh: "终端", en: "Terminal" },
"workbench.text_actions": { zh: "文本操作", en: "Text actions" },
"workbench.the_agent_can_open_web": { zh: "agent 可以打开网页、读取内容并操作页面;浏览器在第一次使用时才启动。关闭后这些工具不会出现在工具列表中", en: "The agent can open web pages, read them and operate them; the browser only starts the first time it is used. When off, these tools are not in the tool list" },
"workbench.let_it_open_pages_in_the_browser": { zh: "让它在内置浏览器里打开网页、点按钮、截图", en: "Let it open pages, click and take screenshots in the built-in browser" },
"workbench.the_connection_timed_out_or": { zh: "连接超时或地址不可达。请确认已连上公司网络或 VPN。", en: "The connection timed out or the address is unreachable. Check that you are on the company network or VPN." },
"workbench.the_connection_was_refused_check": { zh: "对方拒绝了连接。请确认端口正确、服务已经启动。", en: "The connection was refused. Check the port and that the service is running." },
"workbench.the_file_changed_on_disk": { zh: "磁盘内容已变化,未保存的编辑已保留", en: "The file changed on disk. Your unsaved edits have been preserved." },
@@ -50,7 +50,7 @@ describe("the built-in browser switch", () => {
it("says what this workspace runs with when a project file outranks the switch", async () => {
render(<BrowserTools port={port({ enabled: true, effective: false })} onChanged={() => {}} />);
await toggle();
expect(screen.getByText("当前项目的配置文件关闭了它,此工作区不会提供内置浏览器。")).toBeTruthy();
expect(screen.getByText(/当前项目的配置文件关闭了它,此工作区不会提供内置浏览器。/)).toBeTruthy();
});
it("adds nothing when the switch is what runs", async () => {
+9 -21
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, BrowserToolsSettings } from "../port/port";
import { SettingRow } from "./SettingRow";
import { Switch } from "./Switch";
// One switch over whether the browser tools are bound at all. The row reads the
@@ -35,29 +36,16 @@ export function BrowserTools({ port, onChanged }: { port: AgentPort; onChanged:
};
return (
<div className="box">
<div className="lrow">
<span className="tx">
<span className="lb">{t("workbench.enable_the_built_in_browser")}</span>
<span className="ds">{t("workbench.the_agent_can_open_web")}</span>
</span>
<Switch
data-action="browser-tools.enabled"
on={state.enabled}
busy={busy}
label={t("workbench.enable_the_built_in_browser")}
onClick={() => void flip()}
/>
</div>
<>
<SettingRow id="browser-tools" title={t("common.built_in_browser")} desc={t("workbench.let_it_open_pages_in_the_browser")}>
<Switch data-action="browser-tools.enabled" on={state.enabled} busy={busy} label={t("workbench.enable_the_built_in_browser")} onClick={() => void flip()} />
</SettingRow>
{state.effective !== state.enabled && (
<div className="kv">
<span className="k">{t("common.actually_in_force")}</span>
<span className="v">
{t(state.effective ? "workbench.this_projects_config_file_turns_2" : "workbench.this_projects_config_file_turns")}
</span>
</div>
<p className="note">
{t("common.actually_in_force")} · {t(state.effective ? "workbench.this_projects_config_file_turns_2" : "workbench.this_projects_config_file_turns")}
</p>
)}
{error && <div className="why">{error}</div>}
</div>
</>
);
}
@@ -9,7 +9,7 @@ import type { AgentPort } from "../port/port";
afterEach(cleanup);
const toggle = () => screen.findByRole("switch", { name: "询问来自外部的文字" });
const toggle = () => screen.findByRole("switch", { name: "外部文字" });
describe("the ask-on-external-text switch", () => {
it("starts off, says what it covers and what it does not, and saves the opposite of what it shows", async () => {
@@ -19,8 +19,9 @@ describe("the ask-on-external-text switch", () => {
render(<ExternalOrigin port={p} onChanged={changed} />);
const sw = await toggle();
expect(sw.getAttribute("aria-checked")).toBe("false");
expect(screen.getByText(/已授权的 MCP 服务器、改写过或编码过的文字不在其内/)).toBeTruthy();
expect(screen.getByText(/无人可问时(无头 YOLO),这类调用会被拒绝而不是执行/)).toBeTruthy();
const why = screen.getByText("照抄网页或工具结果里的文字去调用工具前先问你").getAttribute("title") ?? "";
expect(why).toMatch(/已授权的 MCP 服务器、改写过或编码过的文字不在其内/);
expect(why).toMatch(/无人可问时(无头 YOLO),这类调用会被拒绝而不是执行/);
await userEvent.click(sw);
expect(save).toHaveBeenCalledWith(expect.objectContaining({ askOnExternalOrigin: true }));
await waitFor(() => expect(sw.getAttribute("aria-checked")).toBe("true"));
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import { reason, savedNotApplied } from "../i18n/kernel";
import type { AgentPort } from "../port/port";
import { SaveNote, type SaveNoteState } from "./SaveNote";
import { SettingRow } from "./SettingRow";
import { Switch } from "./Switch";
// One switch over asking before a call that copies text an external result
@@ -35,15 +36,11 @@ export function ExternalOrigin({ port, onChanged }: { port: AgentPort; onChanged
};
return (
<div className="box">
<div className="lrow">
<span className="tx">
<span className="lb">{t("safety.ask_about_text_from_outside")}</span>
<span className="ds">{t("safety.when_a_call_copies_text")}</span>
</span>
<Switch data-action="permissions.ask-on-external-origin" on={on} busy={busy} label={t("safety.ask_about_text_from_outside")} onClick={() => void flip()} />
</div>
<>
<SettingRow id="external-origin" title={t("settings.outside_text")} desc={t("safety.ask_before_copied_text")} more={t("safety.what_outside_text_covers")}>
<Switch data-action="permissions.ask-on-external-origin" on={on} busy={busy} label={t("settings.outside_text")} onClick={() => void flip()} />
</SettingRow>
<SaveNote note={note} />
</div>
</>
);
}
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
import { SafetySettings } from "./SafetySettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw(mode: "ask" | "yolo" = "ask") {
const go = vi.fn();
const onMode = vi.fn();
render(
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "权限与安全", go }}>
<SafetySettings port={new MockPort() as unknown as AgentPort} mode={mode} busy="" onMode={onMode} onChanged={() => {}} />
</SettingDeepContext.Provider>,
);
return { go, onMode };
}
it("is three cards, every block one row with its scope and when it takes effect", async () => {
draw();
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["批准", "沙箱与执行", "联网"]);
for (const id of ["approval", "rules", "external-origin", "tool-list", "sandbox", "shell", "write-lease", "subagent-claims", "web-access", "browser-tools"]) {
await waitFor(() => expect(document.querySelector(`.prefs-card > [data-setting="${id}"], .prefs-card [data-setting="${id}"]`), id).toBeTruthy());
}
expect(document.querySelector('[data-setting="approval"] .apply')?.textContent).toContain("本机");
});
it("picks an approval mode from the row and opens pages from pills, counting rules and tools", async () => {
const { go, onMode } = draw();
fireEvent.click(screen.getByRole("radio", { name: "自动批准" }));
expect(onMode).toHaveBeenCalledWith("auto");
const rules = await screen.findByRole("button", { name: /^编辑规则(\d+ 条)$/ });
fireEvent.click(rules);
expect(go).toHaveBeenLastCalledWith("rules");
fireEvent.click(await screen.findByRole("button", { name: /^查看与调整(\d+)$/ }));
expect(go).toHaveBeenLastCalledWith("tool-list");
});
it("says what the permissive mode does only while it is picked", () => {
draw("ask");
expect(document.querySelector('[role="status"].note')).toBeNull();
cleanup();
draw("yolo");
expect(document.querySelector('[role="status"].note')).toBeTruthy();
});
@@ -0,0 +1,77 @@
import { useEffect, useState, type ReactNode } from "react";
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import type { ApprovalMode } from "../port/port";
import { APPROVALS, approvalNote } from "./approvals";
import { BrowserTools } from "./BrowserTools";
import { ExternalOrigin } from "./ExternalOrigin";
import { Rules } from "./Rules";
import { Sandbox } from "./Sandbox";
import { SectionCard } from "./SectionCard";
import { SegChoice, SettingRow } from "./SettingRow";
import { SettingDeep } from "./SettingDeep";
import { Shell } from "./Shell";
import { SubagentClaims } from "./SubagentClaims";
import { ToolList } from "./ToolList";
import { WebAccess } from "./WebAccess";
import { WriteLease } from "./WriteLease";
interface Props {
port: AgentPort;
mode: ApprovalMode | undefined;
busy: string;
onMode: (mode: ApprovalMode) => void;
onChanged: () => void;
}
const page = (body: ReactNode) => <section className="prefs-card">{body}</section>;
export function SafetySettings({ port, mode, busy, onMode, onChanged }: Props) {
const [rules, setRules] = useState<number | null>(null);
const [tools, setTools] = useState<number | null>(null);
useEffect(() => {
port.permissions().then((p) => setRules(p.allow.length + p.ask.length + p.deny.length)).catch(() => setRules(null));
port.toolSettings().then((s) => setTools(s.tools.length)).catch(() => setTools(null));
}, [port]);
const details = t("safety.details");
return (
<>
<SectionCard title={t("settings.approval")}>
<SettingRow id="approval" title={t("settings.tool_approvals")} desc={t("safety.ask_before_writing_or_running")}>
<SegChoice label={t("settings.tool_approvals")} value={mode as ApprovalMode} action="tool-approval.mode" disabled={!!busy}
onPick={onMode} options={APPROVALS.map(([id, name]) => [id, t(name)] as const)} />
</SettingRow>
{mode === "yolo" && <p className="note" role="status">{approvalNote(mode)}</p>}
<SettingDeep id="rules" title={t("settings.rules_you_spell_out")} hint={t("safety.allow_and_deny_by_command")}
act={rules === null ? details : t("safety.edit_rules_n", { n: rules })}>
{page(<Rules port={port} onChanged={onChanged} />)}
</SettingDeep>
<ExternalOrigin port={port} onChanged={onChanged} />
<SettingDeep id="tool-list" title={t("ext.tools_the_model_can_use")} hint={t("safety.which_tools_the_model_sees")}
act={tools === null ? details : t("safety.view_and_adjust_n", { n: tools })}>
{page(<ToolList port={port} />)}
</SettingDeep>
</SectionCard>
<SectionCard title={t("settings.sandbox_and_execution")}>
<SettingDeep id="sandbox" title={t("settings.sandbox")} hint={t("safety.commands_write_only_here")} act={details}>
{page(<Sandbox port={port} onChanged={onChanged} />)}
</SettingDeep>
<SettingDeep id="shell" title={t("common.which_program_runs_commands")} hint={t("safety.which_program_runs_a_command")} act={details}>
{page(<Shell port={port} onChanged={onChanged} />)}
</SettingDeep>
<SettingDeep id="write-lease" title={t("session.several_sessions_writing_one_project")} hint={t("safety.when_two_sessions_edit_one")} act={details}>
{page(<WriteLease port={port} />)}
</SettingDeep>
<SettingDeep id="subagent-claims" title={t("session.sub_agent_write_scope")} hint={t("session.each_sub_agents_write_scope")} act={details}>
{page(<SubagentClaims port={port} onChanged={onChanged} />)}
</SettingDeep>
</SectionCard>
<SectionCard title={t("safety.web")}>
<SettingDeep id="web-access" title={t("models.web_search_and_fetch")} hint={t("safety.search_and_fetch_when_needed")} act={details}>
{page(<WebAccess port={port} />)}
</SettingDeep>
<BrowserTools port={port} onChanged={onChanged} />
</SectionCard>
</>
);
}
@@ -1,24 +0,0 @@
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import { Group } from "./Group";
import { SubagentClaims } from "./SubagentClaims";
import { WriteLease } from "./WriteLease";
export function SessionCoordination({ port, onChanged }: { port: AgentPort; onChanged: () => void }) {
return (
<>
<Group id="write-lease"
title={t("session.several_sessions_writing_one_project")}
hint={t("session.how_sessions_in_one_project")}
>
<WriteLease port={port} />
</Group>
<Group id="subagent-claims"
title={t("session.sub_agent_write_scope")}
hint={t("session.how_a_background_sub_agent")}
>
<SubagentClaims port={port} onChanged={onChanged} />
</Group>
</>
);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export function SettingDeep({ id, title, now, hint, act, children }: { id: strin
);
}
return (
<div className="deep-page" data-deep-open={id}>
<div className="deep-page" data-deep-open={id} data-setting={id}>
<nav className="deep-crumb" aria-label={t("settings.where_you_are")}>
<button type="button" data-action="settings.close-page" onClick={() => deep.go(null)}>{deep.section}</button>
<span aria-hidden="true">/</span>
+2 -2
View File
@@ -1,12 +1,12 @@
import type { ReactNode } from "react";
import { ApplyNote } from "./Group";
export function SettingRow({ id, title, desc, children }: { id: string; title: string; desc?: string; children?: ReactNode }) {
export function SettingRow({ id, title, desc, more, children }: { id: string; title: string; desc?: string; more?: string; children?: ReactNode }) {
return (
<div className="srow" data-setting={id}>
<div className="srow-meta">
<span className="srow-name">{title}</span>
{desc && <span className="srow-desc">{desc}</span>}
{desc && <span className="srow-desc" title={more}>{desc}</span>}
<ApplyNote id={id} />
</div>
{children && <div className="srow-ctl">{children}</div>}
+4 -65
View File
@@ -20,7 +20,7 @@ import { Group, SCOPE_SAID } from "./Group";
import { PlanApproval } from "./PlanApproval";
import { CommonSettings } from "./CommonSettings";
import { PeersSection } from "./PeersSection";
import { SessionCoordination } from "./SessionCoordination";
import { SafetySettings } from "./SafetySettings";
import type { Section } from "./prefsnav";
import { AddServer } from "./AddServer";
import { PhoneAccess } from "./PhoneAccess";
@@ -43,18 +43,12 @@ import { SkillGroups } from "./SkillRow";
import { Hooks } from "./Hooks";
import { ScheduledRuns } from "./ScheduledRuns";
import { Network } from "./Network";
import { Shell as ShellPicker } from "./Shell";
import { ExternalOrigin } from "./ExternalOrigin";
import { Rules, ToolList } from "./Rules";
import { ConfigTrouble } from "./ConfigTrouble";
import { Sandbox } from "./Sandbox";
import { BrowserTools } from "./BrowserTools";
import { PresentFiles } from "./PresentFiles";
import { AskTimeout } from "./AskTimeout";
import { SessionRef } from "./SessionRef";
import { TraceExport } from "./TraceExport";
import { ModHost } from "./ModHost";
import { WebAccess } from "./WebAccess";
import { ProgressWatch } from "./ProgressWatch";
import { DisplaySettings } from "./DisplaySettings";
import { SectionCard } from "./SectionCard";
@@ -74,7 +68,7 @@ import { Appearance, SCHEMES } from "./Appearance";
import { ScopeBar } from "./CapabilityScope";
import { savedNotApplied, reason } from "../i18n/kernel";
import type { SettingsProps as Props } from "./SettingsProps";
import { APPROVALS, approvalName, approvalNote } from "./approvals";
import { approvalName } from "./approvals";
// An empty list hides Advanced. Only features available in the legacy app
// without a settings page here belong in this list.
@@ -529,63 +523,8 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
)}
{at === "tools" && (
<>
<SectionCard title={t("settings.approval")} hint={t("settings.when_it_stops_to_ask")}>
<Group id="approval" title={t("settings.tool_approvals")} now={approval} hint={t("settings.this_is_the_only_approval")}>
{/* 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
read when it is picked, not compared four at a time. */}
<div className="seg" data-text data-danger={status?.toolApprovalMode === "yolo" ? "" : undefined}
role="radiogroup" aria-label={t("settings.tool_approvals")}>
{APPROVALS.map(([id, name]) => (
<button key={id} role="radio" data-action="tool-approval.mode" data-value={id}
aria-checked={status?.toolApprovalMode === id} disabled={!!busy}
onClick={() => run(id, () => port.setApprovalMode(id))}>
{t(name)}
</button>
))}
</div>
<p className="note">{approvalNote(status?.toolApprovalMode)}</p>
</Group>
<Group id="external-origin"
title={t("settings.outside_text")}
hint={t("settings.text_in_web_pages_and")}
>
<ExternalOrigin port={port} onChanged={onChanged} />
</Group>
<Group id="rules"
title={t("settings.rules_you_spell_out")}
hint={t("settings.the_setting_above_decides_whether")}
>
<Rules port={port} onChanged={onChanged} />
</Group><ToolList port={port} />
</SectionCard>
<SectionCard title={t("settings.sandbox_and_execution")} hint={t("settings.where_its_commands_can_write")}>
<Group id="sandbox"
title={t("settings.sandbox")}
hint={t("settings.how_far_an_approved_action")}
>
<Sandbox port={port} onChanged={onChanged} />
</Group>
<Group id="shell"
title={t("common.which_program_runs_commands")}
hint={t("settings.every_command_the_agent_runs")}
>
<ShellPicker port={port} onChanged={onChanged} />
</Group>
</SectionCard>
<SectionCard title={t("settings.web_and_browser")} hint={t("settings.search_page_fetching_and_the")}>
<Group id="browser-tools"
title={t("common.built_in_browser")}
hint={t("settings.the_tools_the_agent_uses")}
>
<BrowserTools port={port} onChanged={onChanged} />
</Group>
<WebAccess port={port} />
<SessionCoordination port={port} onChanged={onChanged} />
</SectionCard>
</>
<SafetySettings port={port} mode={status?.toolApprovalMode} busy={busy} onChanged={onChanged}
onMode={(id) => run(id, () => port.setApprovalMode(id))} />
)}
{at === "display" && (
+5 -5
View File
@@ -223,18 +223,18 @@ export const SETTINGS: SettingEntry[] = [
{ section: "session", anchor: "plan-approval", title: "settings.plan_approval", scope: "machine", apply: "immediate", keywords: ["plan", "自动批准", "长任务", "无人值守", "plan_approval", "放行", "询问"] },
{ section: "tools", anchor: "tool-list", page: true, title: "ext.tools_the_model_can_use", scope: "machine", apply: "on-open", keywords: ["禁用", "关闭工具", "tools.disabled", "省 token", "提示词开销", "web_fetch", "denylist"] },
{ section: "tools", anchor: "rules", page: true, title: "settings.rules_you_spell_out", scope: "machine", apply: "runtime-rebuild", keywords: ["permissions", "允许", "拒绝", "配方"] },
{ section: "tools", anchor: "sandbox", title: "settings.sandbox", scope: "machine", apply: "runtime-rebuild", keywords: ["隔离", "联网", "写权限", "ssh-agent"] },
{ section: "tools", anchor: "shell", title: "common.which_program_runs_commands", scope: "machine", apply: "runtime-rebuild", keywords: ["bash", "powershell", "解释器"] },
{ section: "tools", anchor: "sandbox", page: true, title: "settings.sandbox", scope: "machine", apply: "runtime-rebuild", keywords: ["隔离", "联网", "写权限", "ssh-agent"] },
{ section: "tools", anchor: "shell", page: true, title: "common.which_program_runs_commands", scope: "machine", apply: "runtime-rebuild", keywords: ["bash", "powershell", "解释器"] },
// The tools are bound while a runtime is assembled, and the save rebuilds this
// one. The shared [browser] enabled key belongs to the 1.x command line.
// The lease is built with the session and a rebuild adopts the one it holds, so
// the save reaches only sessions opened afterwards; the handler rebuilds nothing.
{ section: "peers", anchor: "peers", page: true, title: "remote.messages_between_sessions_experimental", scope: "machine", apply: "next-turn", keywords: ["peer", "会话间", "消息", "发送", "批准", "挂起", "另一个会话"] },
{ section: "peers", anchor: "peer-projects", title: "remote.projects_allowed_through", scope: "machine", apply: "next-turn", keywords: ["放行", "项目", "不再询问", "长期", "撤销", "peer", "release", "会话间"] },
{ section: "tools", anchor: "write-lease", title: "session.several_sessions_writing_one_project", scope: "machine", apply: "on-open", keywords: ["lease", "写锁", "并行", "多会话", "乐观", "optimistic", "write_lease", "等待"] },
{ section: "tools", anchor: "subagent-claims", title: "session.sub_agent_write_scope", scope: "machine", apply: "runtime-rebuild", keywords: ["subagent", "子代理", "并发", "write_paths", "乐观", "optimistic", "写锁"] },
{ section: "tools", anchor: "write-lease", page: true, title: "session.several_sessions_writing_one_project", scope: "machine", apply: "on-open", keywords: ["lease", "写锁", "并行", "多会话", "乐观", "optimistic", "write_lease", "等待"] },
{ section: "tools", anchor: "subagent-claims", page: true, title: "session.sub_agent_write_scope", scope: "machine", apply: "runtime-rebuild", keywords: ["subagent", "子代理", "并发", "write_paths", "乐观", "optimistic", "写锁"] },
{ section: "account", anchor: "trace-export", title: "data.trace_export", scope: "machine", apply: "on-open", keywords: ["追踪", "opentelemetry", "otlp", "观测", "遥测", "trace", "collector", "收集器"] },
{ section: "tools", anchor: "web-access", title: "models.web_search_and_fetch", scope: "machine", apply: "on-open", keywords: ["搜索", "searxng", "deepseek", "抓取", "计费", "付费", "web search", "fetch"] },
{ section: "tools", anchor: "web-access", page: true, title: "models.web_search_and_fetch", scope: "machine", apply: "on-open", keywords: ["搜索", "searxng", "deepseek", "抓取", "计费", "付费", "web search", "fetch"] },
{ section: "tools", anchor: "browser-tools", title: "common.built_in_browser", scope: "machine", apply: "runtime-rebuild", keywords: ["browser", "网页", "chrome", "浏览器工具"] },
// The four blocks below carry a scope control of their own, and the kernel
// takes that choice as a parameter — hook.Scope, McpInstallScope, the skill
@@ -35,7 +35,7 @@ describe("the permissions page", () => {
await waitFor(() => expect(shown("approval")).toBe(true));
expect(shown("sandbox")).toBe(true);
expect(shown("browser-tools")).toBe(true);
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["批准", "沙箱与执行", "联网与浏览器"]);
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["批准", "沙箱与执行", "联网"]);
expect(document.querySelectorAll(".prefs-page")).toHaveLength(0);
});