refactor(studio): the settings window loses its enlarge mode and keeps one close button

The sheet already fills the window, so enlarging only took it over the title bar and added three window buttons beside its own two. The control, the stored preference, the window buttons it summoned and the drag-region rules that went with it are removed; the board draws no buttons in that corner besides closing.
This commit is contained in:
yhh committed 2026-11-05 19:13:20 -08:00
1 parent 4d29b0bf74
commit 3bf7491840
14 files changed
+39 -179

No files matched your search

-1
View File
@@ -393,7 +393,6 @@ export const ACTIONS: UIAction[] = [
// the row's own action, so this one never writes anything itself.
{ id: "tree.navigate", kind: "view", target: "none", proof: "interaction" },
{ id: "settings.close", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "pane.activate", kind: "navigation", target: "none", proof: "interaction" },
// How this session is being read. One id across a bar button and a menu row,
// because reaching a view from the bar and from the detail menu is one intent
@@ -353,7 +353,7 @@
.ustack .utip .r { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font: var(--w-med) var(--fs-micro) var(--ui); }
.ustack .utip .r b { color: var(--text); font-weight: var(--w-semi); }
.prefs { padding: 24px; }
.prefs-sheet:not([data-wide]) { width: 100%; height: 100%; }
.prefs-sheet { width: 100%; height: 100%; }
.srow-ctl .unit-field input { width: 72px; }
.srow-val { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--ui); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.srow-val > * { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1372,11 +1372,9 @@ export const EN_SETTINGS: Record<string, string> = {
"已自动归档 {n} 个会话,可在“已归档”中恢复。": "{n} conversations were archived automatically. You can restore them from Archived.",
"图标栏": "Icon rail",
"导航入口": "Navigation entries",
"放大设置窗口": "Enlarge the settings window",
"隐藏共同前缀": "Hide the shared prefix",
"{name} 的模型名前缀": "Model name prefix of {name}",
"路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。": "The same prefix a routing service puts on every model (such as group/) is left out of how they are drawn. Display only: model ids do not change, an alias you typed wins, and it takes effect on save.",
"缩小设置窗口": "Shrink the settings window",
"图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。": "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.",
"还原排列": "Reset arrangement",
"上移:{name}": "Move up: {name}",
@@ -44,7 +44,6 @@ export const SETTINGS: Messages = {
"settings.empty_is_automatic_1_to": { zh: "留空表示自动;可填 1 到 {max}", en: "Empty is automatic; 1 to {max}" },
"settings.empty_means_automatic_a_browser": { zh: "默认留空:自动。浏览器对同一个地址只开约 6 个连接,会话列表占一个,所以只让少数标签保持实时连接,其余的标签在显示时从断点接着读,运行中的后台标签每 5 秒读一次状态。调高会让更多后台标签实时更新,但开太多会让停止、发送排不上队。", en: "Empty means automatic. A browser opens only about six connections to one address and the session list takes one, so only a few tabs stay live; the others resume from where they stopped when shown, and a running background tab has its status read every 5 seconds. A higher number updates more background tabs live, but too many makes Stop and Send queue behind them." },
"settings.english": { zh: "英文", en: "English" },
"settings.enlarge_the_settings_window": { zh: "放大设置窗口", en: "Enlarge the settings window" },
"settings.environment_variables": { zh: "环境变量", en: "Environment variables" },
"settings.external_tools": { zh: "外部工具", en: "External tools" },
"settings.file_changes": { zh: "文件改动", en: "File changes" },
@@ -149,7 +148,6 @@ export const SETTINGS: Messages = {
"settings.show_a_status_icon_in": { zh: "在托盘显示图标", en: "Show a status icon in the tray" },
"settings.show_file_changes_above_the": { zh: "在输入框上方显示文件改动", en: "Show file changes above the input box" },
"settings.show_name": { zh: "显示:{name}", en: "Show: {name}" },
"settings.shrink_the_settings_window": { zh: "缩小设置窗口", en: "Shrink the settings window" },
"settings.sign_in_status_could_not": { zh: "无法读取登录状态", en: "Sign-in status could not be read" },
"settings.sign_out": { zh: "退出登录", en: "Sign out" },
"settings.signed_in": { zh: "已登录", en: "Signed in" },
@@ -1,27 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, expect, it } from "vitest";
import { onSettingsWideChange, setSettingsWide, settingsWide } from "./settingsWide";
beforeEach(() => localStorage.clear());
it("is the default size until this machine chooses otherwise", () => {
expect(settingsWide()).toBe(false);
});
it("reads back a choice and tells subscribers", () => {
let heard = 0;
const stop = onSettingsWideChange(() => heard++);
setSettingsWide(true);
expect(settingsWide()).toBe(true);
setSettingsWide(false);
stop();
expect(settingsWide()).toBe(false);
expect(heard).toBe(2);
});
it("reads anything unreadable as the default size", () => {
for (const raw of ["", "yes", "1", "{}"]) {
localStorage.setItem("rx-settings-wide", raw);
expect(settingsWide(), raw).toBe(false);
}
});
@@ -1,27 +0,0 @@
const KEY = "rx-settings-wide";
const listeners = new Set<() => void>();
export function settingsWide(): boolean {
try {
return localStorage.getItem(KEY) === "on";
} catch {
return false;
}
}
export function onSettingsWideChange(fn: () => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
export function setSettingsWide(on: boolean): void {
try {
if (on) localStorage.setItem(KEY, "on");
else localStorage.removeItem(KEY);
} catch {
/* a private window keeps the default, which is the same answer it gives */
}
listeners.forEach((fn) => fn());
}
-2
View File
@@ -2139,8 +2139,6 @@
background: var(--float); box-shadow: var(--shadow-float);
animation: m-panel var(--t-panel) var(--ease-spring) both;
}
.prefs-sheet[data-wide] { width: 100%; height: 100% }
.prefs-sheet[data-wide] .prefs-main .prefs-col { max-width: min(1200px, 100%); margin-inline: auto }
@supports (view-transition-name: none) {
@@ -1,3 +1,3 @@
:root:not([data-fold~="scene"]) :is(.prefs, .fbk-veil, .image-preview, .cpv, .agent-tx-scrim) {
border-bottom: var(--chrome-h, 39px) solid transparent;
} :root:not([data-fold~="scene"]) .prefs:has(> .prefs-sheet[data-wide]) { border-bottom-width: 0; } :root .prefs:has(> .prefs-sheet[data-wide]) { inset: 0; } .prefs-sheet[data-wide] { border-width: 0; border-radius: 0; } [data-titlebar="app"] .prefs-sheet[data-wide] .prefs-hd { -webkit-app-region: drag; } [data-titlebar="app"] .prefs-sheet[data-wide] .prefs-hd :is(button, a[href], input, .winctl) { -webkit-app-region: no-drag; } [data-titlebar="app"][data-platform="darwin"] .prefs-sheet[data-wide] .prefs-hd { padding-left: calc(var(--lights-w, 96px) + 8px); } .prefs-sheet[data-wide] .prefs-hd .winctl { margin-right: -16px; margin-left: 0; }
}
@@ -6,15 +6,3 @@ const css = CSS["./dialogcenter.css"]!;
it("keeps the transparent bottom border that centres a dialog under the title bar", () => {
expect(css).toMatch(/:is\(\.prefs, \.fbk-veil, \.image-preview, \.cpv, \.agent-tx-scrim\)\s*\{\s*border-bottom:\s*var\(--chrome-h, 39px\) solid transparent;/);
});
it("takes it off a settings window that has been enlarged, so the sheet reaches the bottom of the window", () => {
const rule = css.match(/\.prefs:has\(> \.prefs-sheet\[data-wide\]\)\s*\{([^}]*)\}/)?.[1] ?? "";
expect(rule).toMatch(/border-bottom-width:\s*0/);
});
it("lets an enlarged settings window cover the title bar too, with its header standing in for the bar", () => {
expect(css).toMatch(/\.prefs:has\(> \.prefs-sheet\[data-wide\]\)\s*\{\s*inset:\s*0;/);
expect(css).toMatch(/\.prefs-sheet\[data-wide\]\s*\{\s*border-width:\s*0;\s*border-radius:\s*0;/);
expect(css).toMatch(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd\s*\{\s*-webkit-app-region:\s*drag;/);
expect(css).toMatch(/\[data-platform="darwin"\] \.prefs-sheet\[data-wide\] \.prefs-hd\s*\{\s*padding-left:\s*calc\(var\(--lights-w, 96px\) \+ 8px\);/);
});
@@ -1,16 +0,0 @@
import { expect, it } from "vitest";
const CSS = import.meta.glob("./dialogcenter.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const css = CSS["./dialogcenter.css"]!;
// Electron treats everything inside a drag region as part of the title bar, so
// the settings header that takes over the title bar swallows clicks on its own
// buttons and search field unless each is carved back out.
const rulesOf = (re: RegExp) => [...css.matchAll(re)].map((m) => m[1]!).join("\n");
it("makes the full-window settings header a drag region and carves its controls back out", () => {
const drag = css.match(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd\s*\{[^}]*-webkit-app-region:\s*drag/);
expect(drag).not.toBeNull();
const carved = rulesOf(/\[data-titlebar="app"\] \.prefs-sheet\[data-wide\] \.prefs-hd :is\(([^)]*)\)\s*\{[^}]*-webkit-app-region:\s*no-drag[^}]*\}/g);
for (const control of ["button", "input", ".winctl"]) expect(carved).toContain(control);
});
+2 -9
View File
@@ -1,6 +1,4 @@
import { WindowControls } from "./WindowControls";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { onSettingsWideChange, setSettingsWide, settingsWide } from "../state/settingsWide";
import { useCallback, useEffect, useRef, useState } from "react";
import { StudioIcon } from "./StudioIcon";
import { useModalFocus } from "./useModalFocus";
import { SettingsLeaveContext, useSettingsLeave } from "./SettingsLeave";
@@ -77,7 +75,6 @@ const SOLO_TITLE: Record<string, string> = { ext: "ext.extension", usage: "app.u
export function Settings({ hub, onError, port, networkPort, localWorkspacePort, networkHost, status, theme, onTheme, contrast, onContrast, weight, onWeight, look, onLook, onClose, onChanged, onSessionsRecovered, reloadThemes, at: opened, account: acct, accountUnread, reloadAccount, workspaceRoot }: Props) {
const navigation = useSettingsLeave();
const close = useCallback(() => navigation.leave(onClose), [navigation.leave, onClose]);
const wide = useSyncExternalStore(onSettingsWideChange, settingsWide, settingsWide);
const homePort = networkPort ?? port;
const localPort = localWorkspacePort ?? homePort;
const [openedName, namedAnchor = ""] = (opened ?? "").split(":", 2);
@@ -348,7 +345,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
onMouseUp={(e) => { if (veiled.current && e.target === e.currentTarget && !updatingPkg.applying) close(); }}
data-action-mouseup="settings.close"
>
<div className="prefs-sheet" role="dialog" aria-modal="true" aria-labelledby="prefs-title" data-wide={wide || undefined} data-solo={soloSection}>
<div className="prefs-sheet" role="dialog" aria-modal="true" aria-labelledby="prefs-title" data-solo={soloSection}>
<div className="prefs-hd">
<h1 id="prefs-title">{t(solo ? SOLO_TITLE[soloSection] : "common.settings")}</h1>
{/* 原来这里挂着一句「改动立刻生效;需要重建运行时的……」。它对三档里的
@@ -374,13 +371,9 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
}}
/>
<div className="prefs-actions">
<button className="prefs-icon" type="button" data-action="settings.expand" aria-pressed={wide} aria-label={t("settings.enlarge_the_settings_window")} title={t(wide ? "settings.shrink_the_settings_window" : "settings.enlarge_the_settings_window")} onClick={() => setSettingsWide(!wide)}>
<StudioIcon name={wide ? "collapseAll" : "expand"} />
</button>
<button className="prefs-icon" type="button" data-action="settings.close" disabled={updatingPkg.applying} onClick={close} aria-label={`${t("common.close")} Esc`} title={`${t("common.close")} Esc`}>
<StudioIcon name="close" />
</button>
{wide && <WindowControls />}
</div>
{found !== null && (
<div className="prefs-found" role="listbox" aria-label={t("settings.search_settings")}>
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Settings } from "./Settings";
import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
import type { AgentPort, SessionStatus } from "../port/port";
afterEach(cleanup);
function draw(onClose = vi.fn()) {
render(<Settings
hub={new MockHub() as never} port={new MockPort() as unknown as AgentPort}
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
look={{} as never} onLook={() => {}} reloadThemes={() => {}}
onClose={onClose} onChanged={vi.fn()} onError={() => {}} at="appearance"
account={null} accountUnread="" reloadAccount={() => {}}
/>);
return onClose;
}
it("has one control in its corner, the one that closes it", async () => {
const onClose = draw();
const buttons = document.querySelectorAll(".prefs-actions button");
expect(buttons).toHaveLength(1);
expect(screen.queryByRole("button", { name: "放大设置窗口" })).toBeNull();
expect(document.querySelector(".prefs-sheet")?.hasAttribute("data-wide")).toBe(false);
await userEvent.click(buttons[0]!);
await vi.waitFor(() => expect(onClose).toHaveBeenCalled());
});
@@ -1,77 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Settings } from "./Settings";
import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
import type { AgentPort, SessionStatus } from "../port/port";
beforeEach(() => localStorage.clear());
afterEach(cleanup);
function draw(onClose = vi.fn()) {
render(<Settings
hub={new MockHub() as never} port={new MockPort() as unknown as AgentPort}
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
look={{} as never} onLook={() => {}} reloadThemes={() => {}}
onClose={onClose} onChanged={vi.fn()} onError={() => {}} at="appearance"
account={null} accountUnread="" reloadAccount={() => {}}
/>);
return document.querySelector<HTMLElement>(".prefs-sheet")!;
}
it("offers a control that fills the window, and says whether it is on", async () => {
const sheet = draw();
const expand = screen.getByRole("button", { name: "放大设置窗口" });
expect(expand.getAttribute("aria-pressed")).toBe("false");
expect(sheet.hasAttribute("data-wide")).toBe(false);
await userEvent.click(expand);
expect(expand.getAttribute("aria-pressed")).toBe("true");
expect(sheet.hasAttribute("data-wide")).toBe(true);
await userEvent.click(expand);
expect(sheet.hasAttribute("data-wide")).toBe(false);
});
it("opens at the size it was last left at", async () => {
draw();
await userEvent.click(screen.getByRole("button", { name: "放大设置窗口" }));
cleanup();
const sheet = draw();
expect(sheet.hasAttribute("data-wide")).toBe(true);
expect(screen.getByRole("button", { name: "放大设置窗口" }).getAttribute("aria-pressed")).toBe("true");
});
it("draws the arrows outward while the window is small and inward once it is enlarged", async () => {
draw();
const expand = screen.getByRole("button", { name: "放大设置窗口" });
const path = () => expand.querySelector("path")?.getAttribute("d");
const outward = path();
expect(expand.getAttribute("title")).toBe("放大设置窗口");
await userEvent.click(expand);
expect(path()).not.toBe(outward);
expect(expand.getAttribute("title")).toBe("缩小设置窗口");
await userEvent.click(expand);
expect(path()).toBe(outward);
});
it("keeps search Escape and dismissal available after expanding and restoring", async () => {
const onClose = vi.fn();
const sheet = draw(onClose);
const expand = screen.getByRole("button", { name: "放大设置窗口" });
await userEvent.click(expand);
const search = screen.getByRole<HTMLInputElement>("textbox", { name: "搜索设置" });
await userEvent.type(search, "模型");
await userEvent.keyboard("{Escape}");
expect(search.value).toBe("");
expect(onClose).not.toHaveBeenCalled();
expect(sheet.hasAttribute("data-wide")).toBe(true);
await userEvent.click(expand);
expect(sheet.hasAttribute("data-wide")).toBe(false);
await userEvent.click(screen.getByRole("button", { name: /关闭.*Esc/ }));
expect(onClose).toHaveBeenCalledTimes(1);
await userEvent.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(2);
});
+2 -2
View File
@@ -7,7 +7,7 @@
"commented-code": 0,
"complexity": 666,
"css-important": 127,
"css-size": 8878,
"css-size": 8876,
"doc-language": 0,
"doc-owner": 27,
"doc-prose": 1434,
@@ -78,7 +78,7 @@
},
"desktop/frontend-next/src/styles/app.css": {
"css-important": 24,
"css-size": 5260
"css-size": 5258
},
"desktop/frontend-next/src/styles/studio.css": {
"css-important": 103,