Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/desktop/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type {
AppSettings,
Expand Down Expand Up @@ -97,6 +97,13 @@ export function SettingsPage() {
const [settingsRecoveryFailed, setSettingsRecoveryFailed] = useState(false);
const [extensions, setExtensions] = useState<PluginScenicThemesDestinationMeta[]>([]);
const [activeExtension, setActiveExtension] = useState<PluginScenicThemesDestinationMeta | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const destination = activeExtension ? `extension:${activeExtension.ref}` : `builtin:${tab}`;

useLayoutEffect(() => {
// Reset before paint and before the search-anchor effect positions its row.
if (contentRef.current) contentRef.current.scrollTop = 0;
}, [destination]);

useEffect(() => {
const refresh = () => void api.listPluginScenicThemesDestinations().then(setExtensions, () => setExtensions([]));
Expand Down Expand Up @@ -338,7 +345,7 @@ export function SettingsPage() {
</div>
</aside>

<div className="settings-content">
<div className="settings-content" ref={contentRef}>
<div className="settings-content-inner">
<div className="settings-content-enter">
<h1 className="settings-section-title">
Expand Down
5 changes: 5 additions & 0 deletions docs/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

Settings is a **full-window page** that replaces the app sidebar + main chrome (Codex electron behavior):

- Switching to a different Settings destination starts the content pane at the
top, including plugin destinations. Re-selecting the current destination or
updating settings in place preserves the current scroll position. Global
search deep links still scroll to their target row after the destination
changes; consuming the search anchor does not reset the pane again.
- Settings remains usable when an unrelated startup read fails: a successfully
loaded settings snapshot is retained independently from the remaining
bootstrap data. If the settings read itself is unavailable, the content pane
Expand Down
18 changes: 18 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -14508,6 +14508,24 @@ the latest destination. These assertions measure work counts, not device FPS.
cover ownership, deletion, repeated/bounded forks, retained checkpoint paths,
expired inputs, rollback, and symlink rejection.

### E2E-SETTINGS-destination-scroll-reset

- Open Settings → AI and scroll midway down. Select Shortcuts: its title and
first settings appear at the top. Scroll and return to AI: it starts at top.
- Re-select the active destination and update settings without navigating:
the content keeps its scroll position.
- Repeat for built-in → plugin, plugin → plugin, and plugin → the previously
selected built-in destination. Re-selecting a plugin keeps its position.
- Follow a global search setting anchor into AI from another destination and
within AI: the target row is visible, and consuming the anchor keeps that
position.
- Run in light and dark themes.
- Automated coverage: `pnpm test:e2e:settings-scroll` mounts the production
SettingsPage, store, translations, and built CSS in isolated Electron. Only
preload data is stubbed; search navigation uses SearchDialog's public store
entry points. This covers renderer interaction, not host persistence or the
full global-search dialog.

### E2E-SCHEDULED-dispatch

- **Scenario:** Independent task dispatch.
Expand Down
3 changes: 3 additions & 0 deletions docs/zh-CN/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

设置是一个**全窗口页面**,取代了应用程序侧边栏+主镶边(Codex 电子行为):

- 切换到不同的设置分类时,内容区回到顶部,包括插件提供的分类。再次点击
当前分类或在当前页更新设置时,保留滚动位置。通过全局搜索跳转时,仍在
分类切换后定位到目标设置项;清除已消费的搜索锚点不会再次重置滚动位置。
- 左侧设置导航宽 **275px**,与主侧栏共享 `sidebar-surface` 材质:macOS 使用原生
vibrancy 加相同 tint/sheen,Windows/Linux 使用不透明 `--ds-bg-sidebar`,并共享可选背景图。
macOS 下设置外壳透明,右侧内容区与顶部条仍不透明。只有内容区内部的入场包装播放路由入场(仅透明度,不位移),
Expand Down
15 changes: 15 additions & 0 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -8613,6 +8613,21 @@ the latest destination. These assertions measure work counts, not device FPS.
- **验收 / 里程碑**:C、Quality / M6+。
- **状态**:组件与状态层用户路径由 `queue-pending-actions.test.mjs` 覆盖。

### E2E-SETTINGS-destination-scroll-reset

- 打开设置 → AI,滚动到中间,再选择快捷键:标题和首项从顶部显示。滚动后
返回 AI,该页也从顶部显示。
- 再次选择当前分类,或不离开当前页更新设置,保留内容区滚动位置。
- 覆盖内置分类 → 插件、插件 → 插件、插件 → 先前选择的内置分类;再次
选择当前插件分类时保留位置。
- 从其他分类及 AI 当前页通过全局搜索设置锚点进入 AI,目标项可见,消费
锚点后保持定位。
- 在明暗两种主题下运行。
- 自动化覆盖:`pnpm test:e2e:settings-scroll` 在隔离 Electron 中挂载真实
SettingsPage、store、翻译和构建后的 CSS。仅 preload 数据使用 fixture;
搜索导航调用 SearchDialog 使用的公开 store 入口。该测试覆盖渲染层交互,
不覆盖 host 持久化或完整全局搜索弹窗。

### E2E-SCHEDULED-dispatch

- **场景**:独立分发到期任务。
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"test:e2e:hosted-search": "node scripts/e2e-hosted-search.mjs",
"test:e2e:oauth-retry": "node --test apps/desktop/test/anthropic-oauth-retry.test.mjs",
"test:e2e:boot": "node scripts/e2e-electron-boot.mjs",
"test:e2e:settings-scroll": "node scripts/e2e-settings-scroll.mjs",
"test:e2e:layout": "node scripts/e2e-three-column-layout.mjs",
"test:e2e:window-controls": "node scripts/e2e-window-controls.mjs",
"test:e2e:supervision": "node scripts/e2e-supervision.mjs",
Expand Down
58 changes: 58 additions & 0 deletions scripts/e2e-settings-scroll.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node
/** Settings navigation in isolated Electron, with the production component/store/CSS. */
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { repositoryRoot, resolveElectronBinary } from "./e2e/boot.mjs";

const root = repositoryRoot();
const { build } = createRequire(join(root, "packages/agent-runtime/package.json"))("esbuild");
const temp = await mkdtemp(join(tmpdir(), "pi-settings-scroll-"));
try {
await build({ entryPoints: [join(root, "scripts/e2e/settings-scroll.jsx")],
outfile: join(temp, "renderer.js"), bundle: true, platform: "browser", format: "esm", jsx: "automatic",
define: { "process.env.NODE_ENV": '"production"', "import.meta.env.DEV": "false" },
alias: { react: join(root, "apps/desktop/node_modules/react"),
"react-dom": join(root, "apps/desktop/node_modules/react-dom"),
i18next: join(root, "apps/desktop/node_modules/i18next"),
"react-i18next": join(root, "apps/desktop/node_modules/react-i18next") },
nodePaths: [join(root, "apps/desktop/node_modules")],
});
const renderer = join(root, "apps/desktop/out/renderer");
const html = await readFile(join(renderer, "index.html"), "utf8");
const css = [...html.matchAll(/href="([^" ]+\.css)"/g)].map((match) => match[1]);
assert(css.length, "Run pnpm build:js before this test");
await cp(join(renderer, "assets"), join(temp, "assets"), { recursive: true });
await writeFile(join(temp, "index.html"), `<!doctype html><html data-platform="darwin"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:">${css.map((path) => `<link rel="stylesheet" href="${path}">`).join("")}</head><body><div id="root"></div><script type="module" src="renderer.js"></script></body></html>`);
await writeFile(join(temp, "main.cjs"), `
const { app, BrowserWindow } = require("electron");
const path = require("node:path");
app.setPath("userData", path.join(__dirname, "profile"));
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1000, height: 720,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
win.webContents.on("console-message", (event) => console.error(event.message));
try {
await win.loadFile(path.join(__dirname, "index.html"));
const result = await win.webContents.executeJavaScript("window.settingsScrollProbe()");
console.log("SETTINGS_SCROLL " + JSON.stringify(result));
app.exit(0);
} catch (error) { console.error(error); app.exit(1); }
});
`);
const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(resolveElectronBinary(root).electronBinary, [join(temp, "main.cjs")], { env, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { output += chunk; });
const timer = setTimeout(() => child.kill("SIGKILL"), 30_000);
let code;
try { code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); }
finally { clearTimeout(timer); }
assert.equal(code, 0, output);
const result = output.split(/\r?\n/).find((line) => line.startsWith("SETTINGS_SCROLL "));
assert(result, output);
console.log(result);
} finally { await rm(temp, { recursive: true, force: true }); }
108 changes: 108 additions & 0 deletions scripts/e2e/settings-scroll.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Mounted production SettingsPage; only the preload boundary uses fixture data.
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { catalogs, flattenCatalog } from "@pi-desktop/i18n";
import { IPC } from "@pi-desktop/shared";
import { SettingsPage } from "../../apps/desktop/src/features/settings/SettingsPage";
import { useAppStore } from "../../apps/desktop/src/stores/app-store";

const destinations = ["A", "B"].map((id) => ({
ref: `fixture:${id}`, pluginId: "fixture", label: `Themes ${id}`, keywords: [],
description: "Fixture theme collection", themes: Array.from({ length: 12 }, (_, index) => ({
themeId: `plugin:fixture:${id}${index}`, label: `Theme ${index}`, description: "Preview",
previewUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E", blur: 6,
})),
}));
let settings = { defaultMode: "agent", theme: "light", language: "en", enterToSend: true };
window.piDesktop = {
platform: "darwin", on: () => () => {},
async invoke(channel, input) {
let data;
switch (channel) {
case IPC.invoke.pluginScenicThemesDestinations: data = destinations; break;
case IPC.invoke.settingsGet: data = settings; break;
case IPC.invoke.settingsSet: settings = input; data = settings; break;
case IPC.invoke.commandShellList: data = { choices: [], effective: null }; break;
default: throw new Error(`Unexpected fixture IPC: ${channel}`);
}
return { ok: true, data };
},
};
await i18n.use(initReactI18next).init({ lng: "en", fallbackLng: "en", keySeparator: false,
resources: { en: { translation: flattenCatalog(catalogs.en) } }, interpolation: { escapeValue: false } });
useAppStore.setState({ settings, settingsTab: "ai", page: "settings" });
const root = createRoot(document.getElementById("root"));
flushSync(() => root.render(<SettingsPage />));
const frame = () => new Promise(requestAnimationFrame);
async function settle() { await frame(); await frame(); }
function assert(value, message) { if (!value) throw new Error(message); }
const pane = () => document.querySelector(".settings-content");
async function select(label) {
const button = [...document.querySelectorAll(".settings-nav-item")]
.find((node) => node.textContent.trim() === label);
assert(button, `Missing destination: ${label}`);
flushSync(() => button.click());
await settle();
}
async function scroll() {
pane().scrollTop = 220;
await settle();
assert(pane().scrollTop > 0, "Destination must be scrollable for this check");
return pane().scrollTop;
}
window.settingsScrollProbe = async () => {
await settle();
const checks = [];
for (const theme of ["light", "dark"]) {
document.documentElement.dataset.theme = theme;
await select("AI");
const position = await scroll();
await select("AI");
assert(pane().scrollTop === position, "Re-selecting AI must retain scroll");
flushSync(() => useAppStore.setState({ settings: { ...settings, enterToSend: false } }));
await settle();
assert(pane().scrollTop === position, "Settings updates must retain scroll");
await select("Shortcuts");
assert(pane().scrollTop === 0, "AI → Shortcuts must start at top");
await scroll();
await select("AI");
assert(pane().scrollTop === 0, "Returning to AI must start at top");
await scroll();
await select("Themes A");
assert(pane().scrollTop === 0, "Built-in → extension must start at top");
const extensionPosition = await scroll();
await select("Themes A");
assert(pane().scrollTop === extensionPosition, "Re-selecting extension must retain scroll");
await select("Themes B");
assert(pane().scrollTop === 0, "Extension → extension must start at top");
await scroll();
await select("AI");
assert(pane().scrollTop === 0, "Extension → unchanged built-in tab must start at top");
// The same public store entry points used by SearchDialog.openSettingsHit.
await select("Shortcuts");
await scroll();
flushSync(() => {
useAppStore.getState().setSettingsAnchor("settings.enterToSend");
useAppStore.getState().setSettingsTab("ai");
});
await settle();
const target = document.querySelector(".settings-anchor-flash");
assert(target && pane().scrollTop > 0, "Search anchor must win over destination reset");
const bounds = target.getBoundingClientRect();
const viewport = pane().getBoundingClientRect();
assert(bounds.top >= viewport.top && bounds.bottom <= viewport.bottom,
"Search target must be visible");
assert(useAppStore.getState().settingsAnchor === null, "Search anchor must be consumed");
const anchoredPosition = pane().scrollTop;
await settle();
assert(pane().scrollTop === anchoredPosition, "Consuming anchor must not reset scroll");
pane().scrollTop = 0;
flushSync(() => useAppStore.getState().setSettingsAnchor("settings.enterToSend"));
await settle();
assert(pane().scrollTop > 0, "Search within the active tab must still locate its row");
checks.push({ theme, ok: true });
}
return { ok: true, checks };
};
Loading