fix(studio): reset category scrolling and wait for search targets

Problem: switching settings categories retains an unrelated scroll offset, while a searched setting that loads later never receives navigation feedback.

Root cause: categories share one scrolling element and landing resolves the anchor once before asynchronous content arrives.

Fix: key the panel by category, clear pending landing only when a guarded category change succeeds, and observe target arrival until its feedback lifetime begins. Keep existing categories, layouts, disclosures and unsaved-change guards.

Verification: five regressions fail on the unmodified baseline; all 28 settings test files and 215 tests pass with the fix. TypeScript/Vite and repository checks pass. Browser verification confirms a tools panel at scrollTop 5747 switches to network at 0 and search opens the existing advanced contrast disclosure.
This commit is contained in:
SivanCola committed 2026-10-26 01:10:04 +08:00
1 parent 4377886f88
commit d11ca0472c
3 files changed
+177 -28

No files matched your search

+4 -28
View File
@@ -8,6 +8,7 @@ import { listenAction } from "./listen";
import { useRuntimeReload } from "./RuntimeReload";
import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry } from "../port/port";
import { useSettingsSearch } from "./useSettingsSearch";
import { useSettingLanding } from "./useSettingLanding";
import { bytes, tokens as fmtTokens } from "../i18n/format";
import { SECTION_NAME } from "./prefsnav";
import { SettingsNav } from "./SettingsNav";
@@ -316,32 +317,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
});
};
useEffect(() => {
if (!landed) return;
const el = root.current?.querySelector<HTMLElement>(`#set-${CSS.escape(landed)}`);
// 折叠起来的块在 DOM 里,但不在屏幕上 —— 滚过去会停在一片空白上。要开哪一
// 个由结构回答:不需要再维护一张「哪些设置是高级」的表,也不需要把落点穿到
// 每个页面里去。
for (let d = el?.closest("details"); d; d = d.parentElement?.closest("details") ?? null) d.open = true;
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
const aim = () => el?.scrollIntoView({ block: "start", behavior });
aim();
// The page is still growing under the scroll: theme packs and the font list
// arrive late and push the anchor below a target already locked in. A jump
// owns its landing until it lapses, so re-aim for as long as it is owed.
const grew = new ResizeObserver(aim);
const col = root.current?.querySelector(".prefs-col");
if (col) grew.observe(col);
// 「就是这里」是一次导航反馈,不是这个设置变了 —— 所以它只是元素上短暂的
// 一个标记,不进 React 状态,也不用往每个块穿一个纯表现的 prop。
el?.setAttribute("data-landed", "");
const done = setTimeout(() => setLanded(""), 900);
return () => {
clearTimeout(done);
grew.disconnect();
el?.removeAttribute("data-landed");
};
}, [landed, at]);
useSettingLanding(root, landed, at, setLanded);
return (
<SettingsLeaveContext.Provider value={navigation.register}><div
@@ -420,9 +396,9 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
</div>
<ConfigTrouble port={port} onRepaired={onChanged} />
<div className="prefs-body">
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => setAt(section)); }} />
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} />
<div className="prefs-main" role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
<Boundary
fallback={
<div className="find" data-lvl="err" role="alert">
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } 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";
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); });
function draw(at: string, port = new MockPort()) {
render(<Settings
hub={new MockHub()} port={port} status={null}
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}}
weight="" onWeight={() => {}} look={{}} onLook={() => {}}
reloadThemes={() => {}} onClose={() => {}} onChanged={() => {}}
onError={() => {}} at={at} account={null} accountUnread="" reloadAccount={() => {}}
/>);
}
it.each([
["appearance", "版本"],
["tools", "会话"],
])("starts the next category at the top after scrolling %s", async (from, to) => {
draw(from);
const panel = screen.getByRole("tabpanel");
panel.scrollTop = 1800;
panel.scrollLeft = 120;
await userEvent.click(screen.getByRole("tab", { name: new RegExp(`^${to}(?!间消息)`) }));
expect(screen.getByRole("heading", { name: to, level: 2 })).toBeTruthy();
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
expect(screen.getByRole("tabpanel").scrollLeft).toBe(0);
});
it("starts a keyboard-selected category at the top and keeps focus on its tab", async () => {
draw("tools");
const current = screen.getByRole("tab", { name: /^工具与权限/ });
current.focus();
screen.getByRole("tabpanel").scrollTop = 1600;
await userEvent.keyboard("{ArrowDown}");
const next = screen.getByRole("tab", { name: "会话间消息" });
expect(next.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(next);
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
});
it("keeps the current category's scroll and unsaved input when it is selected again", async () => {
draw("persona:persona");
const prefix = await screen.findByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" });
await waitFor(() => expect(prefix.disabled).toBe(false));
await userEvent.type(prefix, "Unsaved draft");
const panel = screen.getByRole("tabpanel");
panel.scrollTop = 650;
await userEvent.click(screen.getByRole("tab", { name: "人设" }));
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "字体");
expect(screen.getByRole("tabpanel").scrollTop).toBe(650);
expect(screen.getByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" }).value).toBe("Unsaved draft");
});
function anchorScrolling() {
return vi.spyOn(Element.prototype, "scrollIntoView").mockImplementation(function (this: Element) {
const panel = this.closest<HTMLElement>('[role="tabpanel"]');
if (panel) panel.scrollTop = 420;
});
}
it("keeps a search result's anchor after switching away from a scrolled category", async () => {
const scroll = anchorScrolling();
draw("session");
screen.getByRole("tabpanel").scrollTop = 1500;
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "柔和");
await userEvent.click(await screen.findByRole("option", { name: /文字对比度/ }));
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
expect(scroll.mock.contexts).toContain(target);
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
});
it("opens an initial anchor inside its disclosure", async () => {
const scroll = anchorScrolling();
draw("appearance:contrast");
await screen.findByRole("heading", { name: "外观", level: 2 });
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
expect(scroll.mock.contexts).toContain(target);
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
});
it("waits for a delayed setting before starting the landing feedback lifetime", async () => {
const port = new MockPort();
const saved = await port.observabilitySettings();
let finish!: (value: typeof saved) => void;
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.useFakeTimers();
const scroll = anchorScrolling();
draw("network:trace-export", port);
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
expect(document.querySelector('[data-setting="trace-export"]')).toBeNull();
await act(async () => { finish(saved); });
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
expect(target.getAttribute("data-landed")).toBe("");
expect(scroll.mock.contexts).toContain(target);
});
it("lands on a searched setting that arrives after navigation", async () => {
const port = new MockPort();
const saved = await port.observabilitySettings();
let finish!: (value: typeof saved) => void;
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
const scroll = anchorScrolling();
draw("session", port);
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "追踪导出");
await userEvent.click(await screen.findByRole("option", { name: /追踪导出/ }));
await act(async () => { finish(saved); });
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
expect(target.getAttribute("data-landed")).toBe("");
expect(scroll.mock.contexts).toContain(target);
});
it("cancels a pending landing when the user chooses another category", async () => {
const port = new MockPort();
const saved = await port.observabilitySettings();
let finish!: (value: typeof saved) => void;
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
draw("network:trace-export", port);
await userEvent.click(screen.getByRole("tab", { name: /^版本/ }));
await act(async () => { finish(saved); });
await userEvent.click(screen.getByRole("tab", { name: /^网络/ }));
const target = await waitFor(() => {
const found = document.querySelector<HTMLElement>('[data-setting="trace-export"]');
expect(found).toBeTruthy();
return found!;
});
expect(target.hasAttribute("data-landed")).toBe(false);
});
@@ -0,0 +1,37 @@
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
export function useSettingLanding(root: RefObject<HTMLDivElement | null>, anchor: string, section: string, setAnchor: Dispatch<SetStateAction<string>>) {
useEffect(() => {
if (!anchor) return;
const col = root.current?.querySelector(".prefs-col");
if (!col) return;
let target: HTMLElement | null = null;
let done: ReturnType<typeof setTimeout> | undefined;
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
const aim = () => {
const found = col.querySelector<HTMLElement>(`#set-${CSS.escape(anchor)}`);
if (!found) return;
for (let parent = found.closest("details"); parent; parent = parent.parentElement?.closest("details") ?? null)
parent.open = true;
if (target !== found) {
target?.removeAttribute("data-landed");
target = found;
target.setAttribute("data-landed", "");
clearTimeout(done);
done = setTimeout(() => setAnchor(""), 900);
}
found.scrollIntoView({ block: "start", behavior });
};
const grew = new ResizeObserver(aim);
const arrived = new MutationObserver(aim);
grew.observe(col);
arrived.observe(col, { childList: true, subtree: true });
aim();
return () => {
clearTimeout(done);
grew.disconnect();
arrived.disconnect();
target?.removeAttribute("data-landed");
};
}, [root, anchor, section, setAnchor]);
}