fix(studio): the jump buttons go straight to the ends; Shift+click steps through expanded blocks

This commit is contained in:
esengine committed 2026-10-15 10:59:38 -07:00
1 parent 0fe09f3a1d
commit d3e45aff25
8 files changed
+84 -10

No files matched your search

+1
View File
@@ -141,6 +141,7 @@ jobs:
node perf/stickyfold.mjs
node perf/crumb.mjs
node perf/hoverradius.mjs
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/explorersize.mjs
node perf/rowhit.mjs
+1
View File
@@ -78,6 +78,7 @@ node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏
node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶部、有自己的底(浅/深)
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
+46
View File
@@ -0,0 +1,46 @@
// The two floating buttons are named for the ends they go to ("back to top", "jump to latest"),
// so one click has to arrive there, whatever expanded blocks lie between. Where a scroller ends up
// is a property of the render, so it is read from the browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=40";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1100, height: 760 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector('[data-pane="flow"]');
const geometry = () => page.locator('[data-pane="flow"]').evaluate((el) => ({ top: Math.round(el.scrollTop), room: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop) }));
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
await page.waitForSelector('.scrolljump button[data-value="up"]');
await page.waitForSelector('.scrolljump button[data-value="down"]');
await page.evaluate(() => document.querySelectorAll("details.tool-disclosure, details.think").forEach((d) => { d.open = true; }));
await page.waitForTimeout(200);
const open = await page.locator("details.tool-disclosure[open], details.think[open]").count();
check("there are expanded blocks between the middle and each end", open >= 2, `${open} expanded blocks`);
check("the conversation is long enough to scroll", (await geometry()).top > 0 && (await geometry()).room > 0);
await page.click('.scrolljump button[data-value="up"]');
await page.waitForTimeout(400);
const atTop = await geometry();
check("one click on the top button reaches the top", atTop.top <= 1, `scrollTop ${atTop.top}`);
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
await page.waitForSelector('.scrolljump button[data-value="down"]');
await page.click('.scrolljump button[data-value="down"]');
await page.waitForTimeout(800);
const atEnd = await geometry();
check("one click on the latest button reaches the end", atEnd.room <= 2, `${atEnd.room}px from the end`);
await page.close();
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
@@ -16,6 +16,8 @@ export const EN_SETTINGS: Record<string, string> = {
"用 {name} 打开": "Open with {name}",
"回到会话顶部": "Back to top",
"跳到最新": "Jump to latest",
"回到会话顶部(Shift+点击:上一个展开的块)": "Back to top (Shift+click: previous expanded block)",
"跳到最新(Shift+点击:下一个展开的块)": "Jump to latest (Shift+click: next expanded block)",
"首字时间": "Time to first token",
"关闭其他浏览器标签": "Close other browser tabs",
"关闭右侧浏览器标签": "Close browser tabs to the right",
+3 -3
View File
@@ -12,7 +12,7 @@ export function ScrollJump({ scroll, flow, active, onJump }: {
scroll: RefObject<HTMLDivElement | null>;
flow: RefObject<HTMLDivElement | null>;
active: boolean;
onJump: (dir: Direction) => void;
onJump: (dir: Direction, step: boolean) => void;
}) {
const [shown, setShown] = useState(NONE);
useEffect(() => {
@@ -47,12 +47,12 @@ export function ScrollJump({ scroll, flow, active, onJump }: {
<div className="scrolljump">
<div>
{shown.up && (
<button type="button" data-action="transcript.scroll" data-value="up" aria-label={t("回到会话顶部")} title={t("回到会话顶部")} onClick={() => onJump("up")}>
<button type="button" data-action="transcript.scroll" data-value="up" aria-label={t("回到会话顶部")} title={t("回到会话顶部(Shift+点击:上一个展开的块)")} onClick={(e) => onJump("up", e.shiftKey)}>
<StudioIcon name="down" />
</button>
)}
{shown.down && (
<button type="button" data-action="transcript.scroll" data-value="down" aria-label={t("跳到最新")} title={t("跳到最新")} onClick={() => onJump("down")}>
<button type="button" data-action="transcript.scroll" data-value="down" aria-label={t("跳到最新")} title={t("跳到最新(Shift+点击:下一个展开的块)")} onClick={(e) => onJump("down", e.shiftKey)}>
<StudioIcon name="down" />
</button>
)}
+11 -3
View File
@@ -360,12 +360,20 @@ export function Transcript({ items, port, entering, onEntered, revision, waiting
follow();
}, [follow]);
// The nearest expanded call or thought in that direction, else the very end:
// one jump should not cross a whole stretch of context the reader has opened.
// The button goes to the end it names. Held with Shift it steps to the nearest
// expanded call or thought in that direction instead, else the end: a jump
// that should not cross a whole stretch of context the reader has opened.
const jumpToward = useCallback(
(dir: Direction) => {
(dir: Direction, step: boolean) => {
const root = scroll.current;
if (!root) return;
if (!step) {
if (dir === "down") return resume();
landing.current++;
release();
root.scrollTop = 0;
return;
}
const base = root.getBoundingClientRect().top - root.scrollTop;
const tops = [...root.querySelectorAll<HTMLElement>("details.tool-disclosure[open], details.think[open]")].map((el) => el.getBoundingClientRect().top - base);
const to = landingOf(dir, tops, root.scrollTop, root.clientHeight, root.scrollHeight);
@@ -68,22 +68,36 @@ describe("the transcript's jump buttons", () => {
expect(down()).toBeNull();
});
it("goes to the nearest expanded call or thought before it goes to the end", async () => {
it("goes straight to the end the button names, past any expanded block", async () => {
const { geometry, settle, expanded } = draw();
expanded("tool-disclosure", 1900);
expanded("think", 2700);
geometry.top = 1000;
await settle();
fireEvent.click(down()!);
expect(geometry.top).toBe(5000);
geometry.top = 3000;
await settle();
fireEvent.click(up()!);
expect(geometry.top).toBe(0);
});
it("steps to the nearest expanded call or thought when Shift is held", async () => {
const { geometry, settle, expanded } = draw();
expanded("tool-disclosure", 1900);
expanded("think", 2700);
geometry.top = 1000;
await settle();
fireEvent.click(down()!);
fireEvent.click(down()!, { shiftKey: true });
expect(geometry.top).toBe(1900 - 8);
await settle();
fireEvent.click(down()!);
fireEvent.click(down()!, { shiftKey: true });
expect(geometry.top).toBe(2700 - 8);
await settle();
fireEvent.click(up()!);
fireEvent.click(up()!, { shiftKey: true });
expect(geometry.top).toBe(1900 - 8);
});
+2
View File
@@ -0,0 +1,2 @@
- 修复:对话右下角“回到会话顶部/跳到最新”按钮一次点击直达两端;原来的“逐个展开块”跳法改为按住 Shift 点击。关联 #13326、#13229。
- Fixed: the jump-to-top and jump-to-latest buttons now go straight to the ends in one click; the old step through expanded blocks is Shift+click. Refs #13326, #13229.