feat(desktop): the message rail spreads over the column and shows where you are, as the Msg-Rail board draws it

Marks sit at each message's place in the whole conversation (a mounted
message by its own box, an unmounted one by its block), filling the column
instead of a compact cluster in the middle. A grey band follows the viewport
and the marks inside it draw darker; a message that changed files draws
longer; stars are orange. Hovering grows the pointed line and two neighbours
each side (22/16/12px) and opens a card: "你的第n条 · 改了n个文件", two lines of
the message, and the click/right-click hint. Marks closer than 5px join into
segments of at most 56px; hovering one lists its messages with their numbers
and stars, and a press on bare track lands on the mark at that height.
Unloaded earlier messages show as a count pill at the top with a tooltip.
Ctrl+Up/Down rings the mark it walks to and shows its card. A landing on a
mounted message glides over 300ms and the message flashes. The rail needs
three of your messages, is hidden in the folded narrow layout, and reduced
motion switches without animating.

The test for BackgroundJob imported Tool from the port, which does not
export it; it comes from the wire module.
This commit is contained in:
esengine committed 2026-11-04 11:31:51 +00:00
1 parent f4b199cc68
commit c370090b8b
16 files changed
+447 -329

No files matched your search

+1
View File
@@ -783,6 +783,7 @@ export const ACTIONS: UIAction[] = [
// gutter's own grip, which this pass has not annotated yet.
{ id: "rail.star", kind: "view", target: "none", proof: "interaction" },
{ id: "rail.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "rail.jump", kind: "view", target: "none", proof: "interaction" },
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
...SCHEDULE_ACTIONS,
...DOCUMENT_ACTIONS,
@@ -11,3 +11,4 @@
@import "./jobs.css" layer(design);
@import "./workbench.css" layer(design);
@import "./palette.css" layer(design);
@import "./rail.css" layer(design);
+77
View File
@@ -0,0 +1,77 @@
.railhost { position: sticky; top: 0; height: 0; z-index: 4; pointer-events: none; }
[data-fold~="rail"][data-fold~="side"] .railhost { display: none; }
.srail { position: absolute; top: 0; right: 8px; width: 30px; height: var(--rail-h, 0px); pointer-events: auto; }
.srail-band {
position: absolute; right: 2px; width: 26px; border-radius: var(--r-sm); background: var(--overlay); pointer-events: none;
transition: top 300ms cubic-bezier(.2, 0, 0, 1), height 300ms cubic-bezier(.2, 0, 0, 1);
}
.srail-m {
position: absolute; right: 2px; width: 26px; height: 12px; margin-top: -6px; z-index: 1;
display: flex; align-items: center; justify-content: flex-end;
padding: 0 4px 0 0; border: 0; border-radius: var(--r-xs); background: none; cursor: pointer;
}
.srail-m > i {
width: var(--w, 10px); height: 2px; flex: none; border-radius: 1px; background: var(--border);
transition: width var(--t-line) cubic-bezier(.2, 0, 0, 1), background-color var(--t-line);
}
.srail-m[data-folded] > i { height: var(--h, 4px); border-radius: var(--r-xs); opacity: .9; }
.srail-m[data-seen] > i { background: var(--muted); }
.srail-m[data-starred] > i { background: var(--warn); }
.srail-m[data-on] > i { background: var(--text); }
.srail-m:focus-visible, .srail-m[data-keyed] { outline: none; box-shadow: 0 0 0 2px var(--focus); }
.srail-peek, .srail-sub {
position: absolute; right: 46px; z-index: 5; width: 250px; box-sizing: border-box;
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float);
transform: translateY(-50%);
}
.srail-peek { padding: 9px 11px; pointer-events: none; animation: srail-peek var(--t-line) cubic-bezier(.16, 1, .3, 1) both; }
@keyframes srail-peek { from { opacity: 0; transform: translate(6px, -50%); } }
.srail-peek .hd { display: flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-semi) var(--fs-meta)/1.4 var(--ui); }
.srail-peek .hd .dot { color: var(--faint); }
.srail-peek .tx {
margin-top: 3px; color: var(--text); font: var(--w-med) var(--fs-ui)/1.55 var(--ui);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.srail-peek .keys { margin-top: 6px; color: var(--faint); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); }
.srail-sub { z-index: 6; max-height: 300px; overflow-y: auto; padding: 5px; pointer-events: auto; }
.srail-subhd { padding: 4px 8px 6px; color: var(--muted); font: var(--w-semi) var(--fs-meta)/1.4 var(--ui); }
.srail-subrow {
display: flex; align-items: center; gap: 8px; width: 100%; height: 30px; padding: 0 8px;
border: 0; border-radius: var(--r-sm); background: none; color: var(--text);
font: var(--w-med) var(--fs-meta)/1 var(--ui); text-align: start; cursor: pointer;
}
.srail-subrow:hover, .srail-subrow:focus-visible { outline: none; background: var(--float-hi); }
.srail-subrow .n { width: 22px; flex: none; color: var(--faint); font-variant-numeric: tabular-nums; }
.srail-subrow .tx { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.srail-star { width: 12px; height: 12px; flex: none; fill: var(--warn); }
.srail-earlier {
position: absolute; top: 8px; right: 12px; display: inline-flex; align-items: center; gap: 3px;
height: 22px; padding: 0 7px; border: 1px solid var(--border); border-radius: var(--r-pill);
background: var(--raised); color: var(--muted); font: var(--w-semi) var(--fs-micro)/1 var(--ui);
cursor: pointer; pointer-events: auto;
}
.srail-earlier .studio-icon { width: 10px; height: 10px; }
.srail-earlier:hover { color: var(--text); }
.srail-tip {
position: absolute; top: 50%; right: calc(100% + 8px); transform: translateY(-50%);
padding: 6px 9px; border-radius: var(--r-sm); background: var(--text); color: var(--surface);
font: var(--w-med) var(--fs-meta)/1.3 var(--ui); white-space: nowrap; opacity: 0; pointer-events: none;
transition: opacity var(--t-line);
}
.srail-earlier:hover .srail-tip, .srail-earlier:focus-visible .srail-tip { opacity: 1; }
.flow .call[data-k="me"][data-hit] .out .txt { animation: srail-flash 900ms ease; }
@keyframes srail-flash {
30% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--net) 45%, transparent); }
100% { box-shadow: 0 0 0 0 transparent; }
}
@media (prefers-reduced-motion: reduce) {
.srail-band, .srail-m > i, .srail-tip { transition: none; }
.srail-peek { animation: none; }
}
+3 -6
View File
@@ -211,7 +211,7 @@ export const CHAT: Messages = {
"chat.its_own_context_stays_out": { zh: "独立上下文 · 不进主轨迹", en: "Its own context · stays out of the main trajectory" },
"chat.jump_to_latest": { zh: "跳到最新", en: "Jump to latest" },
"chat.jump_to_latest_shift_click": { zh: "跳到最新(Shift+点击:下一个展开的块)", en: "Jump to latest (Shift+click: next expanded block)" },
"chat.jump_to_questions_a_to": { zh: "定位到第 {a} 至 {b} 条", en: "Jump to questions {a} to {b}" },
"chat.jump_to_questions_a_to": { zh: "定位到第{a}至{b}条", en: "Jump to messages {a} to {b}" },
"chat.jump_to_what_you_said": { zh: "定位到你说的:{text}", en: "Jump to what you said: {text}" },
"chat.jump_to_where_a_symbol": { zh: "跳转到符号的定义位置,由语言服务器提供。", en: "Jump to where a symbol is defined, answered by the language server." },
"chat.keep_the_current_one": { zh: "保留当前版本", en: "Keep the current one" },
@@ -239,7 +239,6 @@ export const CHAT: Messages = {
"chat.mcp_server_name_is_asking": { zh: "MCP 服务器 {name} 请求信息", en: "MCP server {name} is asking for information" },
"chat.mcp_server_name_is_asking_2": { zh: "MCP 服务器 {name} 请求外部操作", en: "MCP server {name} is asking for an external interaction" },
"chat.medium_risk": { zh: "中风险", en: "Medium risk" },
"chat.messages_a_to_b": { zh: "第{a}至{b}条", en: "Messages {a} to {b}" },
"chat.model_window": { zh: "模型窗口", en: "Model window" },
"chat.more_actions_for_name": { zh: "更多操作:{name}", en: "More actions for {name}" },
"chat.move_file": { zh: "移动文件", en: "Move file" },
@@ -256,7 +255,7 @@ export const CHAT: Messages = {
"chat.n_matches": { zh: "{n} 处", en: "{n} matches" },
"chat.n_more": { zh: "还有 {n} 项", en: "{n} more" },
"chat.n_more_are_index_entries": { zh: "另有 {n} 处仅保留索引地址,需通过 recall 取回原文", en: "{n} more are index entries only; recall fetches the original" },
"chat.n_more_messages_before_these": { zh: "更早还有 {n} 条消息,点击加载", en: "{n} more messages before these — click to load" },
"chat.n_more_messages_before_these": { zh: "更早还有{n}条,点击加载", en: "{n} earlier messages — click to load" },
"chat.n_running": { zh: "{n} 项运行中", en: "{n} running" },
"chat.n_selected": { zh: "已选 {n} 个", en: "{n} selected" },
"chat.n_still_held_after_the": { zh: "折叠后仍有 {n}:{top}", en: "{n} still held after the fold: {top}" },
@@ -311,7 +310,7 @@ export const CHAT: Messages = {
"chat.project_root": { zh: "项目根目录", en: "Project root" },
"chat.put_this_file_back_the": { zh: "将该文件还原至本次会话开始时的状态 —— 会话中对它的其他改动也会一并撤销。", en: "Put this file back the way it was when the session started — other changes made to it during the session go too." },
"chat.question_n": { zh: "问题 {n}", en: "Question {n}" },
"chat.questions_a_to_b": { zh: "第 {a} 至 {b} 条", en: "Questions {a} to {b}" },
"chat.questions_a_to_b": { zh: "第{a}至{b}条", en: "Messages {a} to {b}" },
"chat.quote_into_the_composer": { zh: "引用到输入框", en: "Quote into the composer" },
"chat.read": { zh: "读取", en: "Read" },
"chat.read_a_browser_page_a": { zh: "读取浏览器页面的内容:快照、查找或截图。", en: "Read a browser page: a snapshot, a find, or a screenshot." },
@@ -358,8 +357,6 @@ export const CHAT: Messages = {
"chat.rewrite": { zh: "改写", en: "Rewrite" },
"chat.rewrite_this_message": { zh: "改写这条消息", en: "Rewrite this message" },
"chat.rewrite_this_message_and_send": { zh: "改写这条消息并重新发送", en: "Rewrite this message and send it again" },
"chat.right_click_to_remove_the": { zh: "右键取消这处星标", en: "Right-click to remove the star" },
"chat.right_click_to_star_this": { zh: "右键给这处加星标", en: "Right-click to star this question" },
"chat.risk_unknown": { zh: "风险未知", en: "Risk unknown" },
"chat.run_a_command_in_the": { zh: "在命令行里执行命令并返回输出,用于构建、测试、git、包管理等。", en: "Run a command in the shell and return its output, for builds, tests, git and package managers." },
"chat.run_a_short_starlark_python": { zh: "运行一小段 Starlark(类 Python)脚本来调用多个工具,几步相互依赖的操作只需一次往返,只返回脚本打印的内容;每次调用都和模型直接调用一样受检查和审批。", en: "Run a short Starlark (Python-like) script that calls several tools, so dependent steps take one round trip and only what the script prints comes back; every call is checked and approved as if the model made it directly." },
-48
View File
@@ -772,49 +772,6 @@
[data-pane="flow"] { overflow-x: hidden; scrollbar-width: none }
[data-pane="flow"]::-webkit-scrollbar { display: none }
.srail {
position: absolute; top: 0; right: 0; width: var(--srail-w); pointer-events: auto;
height: var(--rail-h, 0px);
}
.srail-m {
position: absolute; right: 15px; width: 39px; height: 18px; margin-top: -9px;
display: flex; align-items: center; justify-content: flex-end;
border: 0; padding: 0; background: none; cursor: pointer;
z-index: calc(1 + var(--lit, 0) * 10);
}
.srail-m > i {
--lit: 0;
width: calc(11px + 15px * var(--lit)); height: 2px; flex: none;
margin-right: 3px; border-radius: var(--r-pill); background: var(--ghost);
opacity: calc(.3 + .55 * var(--lit));
transition: width var(--m-line), opacity var(--m-line), background-color var(--m-line);
}
.srail-m[data-folded] > i { height: 3px }
.srail-m[data-edits] > i { width: calc(18px + 15px * var(--lit)) }
.srail-m[data-starred]::after { content: ""; position: absolute; right: 0; width: 5px; height: 5px; border-radius: 50%; background: var(--accent-ink) }
.srail-m:focus-visible > i { outline: 2px solid var(--focus); outline-offset: 3px }
.srail-m[data-on] > i { background: var(--accent-ink) }
.pane:hover .srail-m > i { opacity: calc(.46 + .54 * var(--lit)) }
.srail:hover ~ .srail-peek { opacity: 1 }
.srail-peek {
position: absolute; right: 48px; z-index: 5; width: 300px; padding: 9px 11px;
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float);
box-shadow: var(--shadow-float); pointer-events: none; transform: translateY(-50%);
animation: peekin var(--m-line) both;
}
@keyframes peekin { from { opacity: 0; transform: translate(8px, -50%) } to { opacity: 1; transform: translateY(-50%) } }
.srail-peek .hd { display: flex; align-items: baseline; gap: 8px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.srail-peek .hd .n { color: var(--accent-ink) }
.srail-peek .keys { margin-top: 6px; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui) }
.srail-peek .tx {
margin-top: 5px; font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text);
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.call[data-hit] > .c > .hl > :is(.nm, .arg),
.call[data-hit] > .c > .tool-disclosure > .hl > :is(.nm, .arg) { animation: hitmark 1.1s var(--ease-out) }
.call[data-hit]:not([data-running]) > .g .sym { animation: hitglyph 1.1s var(--ease-out) }
@@ -2156,11 +2113,6 @@
[data-fold~="side"] .compose { width: calc(100% - 28px) }
[data-fold~="rail"][data-fold~="side"] .pane { --srail-w: 24px }
[data-fold~="rail"][data-fold~="side"] .srail-m { right: 9px; width: 15px }
[data-fold~="rail"][data-fold~="side"] .srail-m > i { max-width: 14px !important }
[data-fold~="rail"][data-fold~="side"] .srail-peek {
right: 26px; width: min(280px, calc(100vw - 54px));
}
[data-fold~="side"] .chrome .goal,
[data-fold~="side"] .chrome .crumb .sep:last-of-type { display: none }
[data-fold~="side"] .side { border-left: 0; border-top: 1px solid var(--border); max-height: calc(40vh / var(--zoom, 1)) }
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
// pointer events off and every child that a person can press has to turn them
// back on. Forgetting that is invisible to jsdom — it does no hit testing — and
// shows up only as "the rows cannot be clicked" in a real browser.
const SHEETS = import.meta.glob("./{app,studio}.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const SHEETS = import.meta.glob(["./{app,studio}.css", "../design/rail.css"], { query: "?raw", import: "default", eager: true }) as Record<string, string>;
interface Rule {
selector: string;
@@ -583,14 +583,6 @@ body { background: var(--page) !important; }
background: color-mix(in srgb, var(--text) 42%, transparent);
background-clip: padding-box;
}
.srail { right: 10px; width: 27px; }
.srail-m { right: 1px; width: 25px; height: 18px; margin-top: -9px; z-index: 1; }
.srail-m > i {
width: calc(8px + 13px * var(--lit)); height: 2px; margin-right: 2px;
background: color-mix(in srgb, var(--text) 38%, transparent);
}
.srail-m[data-on] > i { background: #91a7df; }
.srail-peek { right: 42px; width: min(300px, calc(100vw - var(--rail-w) - 78px)); } .srail-earlier { right: 12px; width: 3px; height: 10px; margin-top: -5px; z-index: 2; padding: 0; border: 0; border-radius: 2px; background: color-mix(in srgb, var(--text) 26%, transparent); cursor: pointer; pointer-events: auto; } .srail-earlier:hover { background: var(--accent-ink) } .srail-sub { position: absolute; right: 42px; z-index: 6; width: 220px; max-height: 260px; overflow-y: auto; padding: 6px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); transform: translateY(-50%); pointer-events: auto; } .srail-subhd { padding: 2px 6px 4px; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); } .srail-subrow { display: block; width: 100%; padding: 4px 6px; border: 0; border-radius: var(--r-sm); background: none; color: var(--text); font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); text-align: start; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .srail-subrow:hover { background: var(--overlay) }
.flow {
width: min(var(--doc), calc(100% - var(--srail-w) - 44px));
margin-inline: auto;
+85 -25
View File
@@ -18,27 +18,87 @@ describe("conversation overview rail", () => {
expect(railLocalY(100 + 300, 100, 600, 600)).toBe(300);
});
it("keeps sparse messages in a compact ordered fisheye", async () => {
const draw = (marks: RailMark[], over: Partial<{ scrollTop: number; scrollHeight: number; earlier: number; onLoadEarlier: () => void; total: number }> = {}) => {
const root = document.createElement("div");
const flow = document.createElement("div");
Object.defineProperties(root, {
clientHeight: { configurable: true, value: 600 },
scrollHeight: { configurable: true, value: 2400 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: over.scrollHeight ?? 2400 },
scrollTop: { configurable: true, writable: true, value: over.scrollTop ?? 0 },
});
const marks = [mark("first", 0, "第一条"), mark("last", 99, "最后一条")];
const onJump = vi.fn();
const view = render(
<Rail
marks={marks} total={100}
scroll={{ current: root }} flow={{ current: flow }}
onJump={vi.fn()} bound scope="test"
/>,
<Rail marks={marks} total={over.total ?? 100} scroll={{ current: root }} flow={{ current: document.createElement("div") }}
onJump={onJump} bound scope="test" earlier={over.earlier} onLoadEarlier={over.onLoadEarlier} />,
);
return { view, onJump };
};
const three = () => [mark("a", 0, "第一条"), { ...mark("b", 50, "第二条"), files: 3 }, mark("c", 99, "第三条")];
const lines = (view: ReturnType<typeof render>) => [...view.container.querySelectorAll<HTMLElement>(".srail-m")];
const buttons = view.getAllByRole("button");
await waitFor(() => expect(buttons[0].style.top).toBe("295.5px"));
expect(buttons[1].style.top).toBe("304.5px");
expect(buttons[0].getAttribute("aria-label")).toBe("定位到你说的:第一条");
it("spreads a few messages over the whole column instead of bunching them in the middle", async () => {
const { view } = draw(three());
await waitFor(() => expect(lines(view)[0].style.top).toBe("18px"));
expect(lines(view)[2].style.top).toBe("582px");
expect(lines(view)[0].getAttribute("aria-label")).toBe("定位到你说的:第一条");
});
it("shows nothing until there are three of your messages", () => {
const { view } = draw([mark("a", 0, "一"), mark("b", 1, "二")]);
expect(view.container.querySelector(".srail")).toBeNull();
});
it("shades the stretch on screen and draws the marks inside it darker", async () => {
const { view } = draw(three(), { scrollTop: 1000, scrollHeight: 2400 });
await waitFor(() => expect(view.container.querySelector(".srail-band")).not.toBeNull());
expect(lines(view).map((b) => b.hasAttribute("data-seen"))).toEqual([false, true, false]);
});
it("draws no band when the whole conversation fits", async () => {
const { view } = draw(three(), { scrollHeight: 600 });
await waitFor(() => expect(lines(view)[0].style.top).toBe("18px"));
expect(view.container.querySelector(".srail-band")).toBeNull();
});
it("draws a message that changed files longer, and magnifies the pointed one and its neighbours", async () => {
const { view } = draw([mark("a", 0, "一"), mark("b", 25, "二"), { ...mark("c", 50, "三"), files: 2 }, mark("d", 75, "四"), mark("e", 99, "五")]);
await waitFor(() => expect(lines(view)[4].style.top).toBe("582px"));
const width = () => lines(view).map((b) => (b.firstElementChild as HTMLElement).style.getPropertyValue("--w"));
expect(width()).toEqual(["10px", "10px", "14px", "10px", "10px"]);
const nav = view.container.querySelector(".srail") as HTMLElement;
nav.getBoundingClientRect = () => ({ top: 0, height: 600, left: 0, width: 30, right: 30, bottom: 600, x: 0, y: 0, toJSON: () => ({}) });
fireEvent.mouseMove(nav, { clientY: parseFloat(lines(view)[1].style.top) });
expect(width()).toEqual(["16px", "22px", "20px", "12px", "10px"]);
});
it("says which of your messages it is and how many files it changed, with no spaces round the numbers", async () => {
const { view } = draw(three());
await waitFor(() => expect(lines(view)[2].style.top).toBe("582px"));
const nav = view.container.querySelector(".srail") as HTMLElement;
nav.getBoundingClientRect = () => ({ top: 0, height: 600, left: 0, width: 30, right: 30, bottom: 600, x: 0, y: 0, toJSON: () => ({}) });
fireEvent.mouseMove(nav, { clientY: parseFloat(lines(view)[1].style.top) });
expect(view.container.querySelector(".srail-peek .hd")?.textContent).toBe("你的第2条·改了3个文件");
expect(view.container.querySelector(".srail-peek .tx")?.textContent).toBe("第二条");
});
it("marks the earlier messages still on disk at the top, and loads them on a press", async () => {
const onLoadEarlier = vi.fn();
const { view } = draw(three(), { earlier: 24, onLoadEarlier });
const more = await waitFor(() => view.getByRole("button", { name: "更早还有24条,点击加载" }));
expect(more.textContent).toContain("24");
await waitFor(() => expect(lines(view)[0].style.top).toBe("52px"));
fireEvent.click(more);
expect(onLoadEarlier).toHaveBeenCalledTimes(1);
});
it("walks your messages with Ctrl and the arrow keys, ringing the one it is on and showing its card", async () => {
const { view, onJump } = draw(three());
await waitFor(() => expect(lines(view)[0].style.top).toBe("18px"));
fireEvent.keyDown(document.body, { key: "ArrowDown", ctrlKey: true });
expect(onJump).toHaveBeenLastCalledWith(expect.objectContaining({ id: "b" }));
expect(lines(view)[1].hasAttribute("data-keyed")).toBe(true);
expect(view.container.querySelector(".srail-peek .tx")?.textContent).toBe("第二条");
fireEvent.keyDown(document.body, { key: "ArrowDown", ctrlKey: true });
expect(onJump).toHaveBeenLastCalledWith(expect.objectContaining({ id: "c" }));
});
});
@@ -62,11 +122,11 @@ describe("a rail longer than it can show", () => {
return { view, marks, onJump };
};
it("draws one mark per distinguishable position, not one per message", async () => {
it("joins messages too close to tell apart into runs, not one line per message", async () => {
const { view } = await rail(2000);
const drawn = view.container.querySelectorAll(".srail-m").length;
expect(drawn).toBeLessThanOrEqual(300);
expect(drawn).toBeGreaterThan(50);
expect(drawn).toBeLessThan(100);
expect(view.container.querySelectorAll(".srail-m[data-folded]").length).toBe(drawn);
});
it("draws every mark while they are far enough apart to tell apart", async () => {
@@ -79,12 +139,11 @@ describe("a rail longer than it can show", () => {
const nav = view.container.querySelector(".srail") as HTMLElement;
nav.getBoundingClientRect = () => ({ top: 0, height: 600, left: 0, width: 20, right: 20, bottom: 600, x: 0, y: 0, toJSON: () => ({}) });
fireEvent.mouseMove(nav, { clientY: 300 });
fireEvent.click(nav);
fireEvent.click(nav, { clientY: 300 });
expect(onJump).toHaveBeenCalledTimes(1);
const picked = Number((onJump.mock.calls[0][0] as RailMark).id.slice(1));
// 2000 questions in 600px are folded into runs, so a press lands on the run
// that holds the thousandth rather than on the thousandth itself.
expect(Math.abs(picked - 1000)).toBeLessThanOrEqual(40);
// Inside a run the pointer's height still names one message.
expect(Math.abs(picked - 1000)).toBeLessThanOrEqual(4);
});
it("opens a folded run on hover, with no press at all", async () => {
@@ -126,7 +185,8 @@ describe("a rail longer than it can show", () => {
expect(rows.length).toBeGreaterThan(1);
// Second press, on a row: that question, exactly.
const label = rows[1].textContent ?? "";
expect(rows[1].querySelector(".n")?.textContent).toBe(String(Number(rows[0].querySelector(".n")?.textContent) + 1));
const label = rows[1].querySelector(".tx")?.textContent ?? "";
fireEvent.click(rows[1]);
expect(onJump).toHaveBeenCalledTimes(1);
expect((onJump.mock.calls[0][0] as RailMark).text).toBe(label);
@@ -146,7 +206,7 @@ describe("a rail longer than it can show", () => {
});
describe("a star belongs to the question, not the row id", () => {
const two = (a: string, b: string): RailMark[] => [mark(a, 0, "第一条"), mark(b, 1, "第二条")];
const two = (a: string, b: string): RailMark[] => [mark(a, 0, "第一条"), mark(b, 1, "第二条"), mark(`${a}-3`, 2, "第三条")];
const draw = (marks: RailMark[], scope: string) => {
const root = document.createElement("div");
@@ -191,14 +251,14 @@ describe("a star belongs to the question, not the row id", () => {
it("keeps the star when a notice shifts the rendered index", async () => {
localStorage.clear();
// Two user turns at rendered positions 0 and 1.
const first = draw([mark("i1", 0, "第一条", 2), mark("i2", 1, "第二条", 3)], "s1");
const first = draw([mark("i1", 0, "第一条", 2), mark("i2", 1, "第二条", 3), mark("i3", 2, "第三条", 4)], "s1");
fireEvent.contextMenu(first.getAllByRole("button")[0]);
await waitFor(() => expect(first.getAllByRole("button")[0].getAttribute("data-starred")).toBe(""));
first.unmount();
// A reload puts a notice above them: `at` moves to 1 and 2 while the logged
// index stays 2 and 3, so the star follows the question, not the position.
const again = draw([mark("j1", 1, "第一条", 2), mark("j2", 2, "第二条", 3)], "s1");
const again = draw([mark("j1", 1, "第一条", 2), mark("j2", 2, "第二条", 3), mark("j3", 3, "第三条", 4)], "s1");
await waitFor(() => expect(again.getAllByRole("button")[0].getAttribute("data-starred")).toBe(""));
expect(again.getAllByRole("button")[1].getAttribute("data-starred")).toBeNull();
});
+128 -230
View File
@@ -1,14 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type RefObject } from "react";
import { t } from "../i18n";
import { renderedBox } from "./findland";
import { StudioIcon } from "./StudioIcon";
import { fold, grow, HEAD, nearest, place, viewBand, within, type RailView } from "./railgeom";
export interface RailMark {
id: string;
text: string;
// Which block holds it and where inside that block, because the block is what
// survives virtualisation: an unmounted one is a placeholder with the right
// height, so its position is known even when the message itself has no node.
// Position in the transcript, in entries. The rail no longer places by it
// (see layout), but a jump still lands by block and offset.
// Position in the transcript, in entries: where the mark is drawn while the
// blocks have no measured geometry to place it by.
at: number;
// The message's index in the session log, which survives a reload: `at` is an
// index into the rendered list, and that list drops host chrome, notices and
@@ -44,27 +44,6 @@ interface Props {
onLoadEarlier?: () => void;
}
// The native scrollbar owns continuous position. The semantic rail is a compact
// fisheye index, like Codex: questions stay together in reading order and only
// the neighbourhood under the pointer expands. Spreading sparse turns across
// the full height makes two messages look like unrelated controls.
const STEP = 9;
const PAD = 26;
// Marks closer than this are not told apart by eye or by pointer, so only one of
// them is drawn; the rest stay addressable through their positions.
const MIN_GAP = 2;
// Below this spacing a mark stops being a mark and becomes part of a crowd: the
// lines blur into one another and no pointer can pick out the fourth of nine.
const CLUSTER_AT = 5;
// Length and weight fall away from whichever mark the pointer has picked out,
// and every mark is the same until one is picked. A cosine rather than a line:
// a linear falloff has a corner at the focus, which reads as the neighbours
// being a different kind of thing rather than the same thing further away.
const REACH = 5;
// A star belongs to one question in one conversation. It is stored by the
// question's position in the transcript, not by the row's id: `nextId` is a
// per-load counter, so "i7" names a different message after a reload — and the
@@ -96,53 +75,6 @@ function saveBookmarks(scope: string, marks: Set<string>) {
}
}
function layout(count: number, rail: number) {
if (count <= 0) return [] as number[];
const room = Math.max(0, rail - PAD * 2);
const step = count > 1 ? Math.min(STEP, room / (count - 1)) : STEP;
const span = (count - 1) * step;
const start = rail / 2 - span / 2;
return Array.from({ length: count }, (_, i) => start + i * step);
}
/** One drawn thing: a single mark, or a run of them folded together.
*
* Folding only starts where reading them apart has already failed: while the
* spacing is at least CLUSTER_AT every question keeps its own line, so a
* conversation of ordinary length looks exactly as it did before. */
export interface RailView {
top: number;
from: number;
to: number;
single: boolean;
}
export function cluster(count: number, rail: number): RailView[] | null {
if (count <= 0) return null;
const room = Math.max(0, rail - PAD * 2);
const step = count > 1 ? Math.min(STEP, room / (count - 1)) : STEP;
if (step >= CLUSTER_AT) return null;
const slots = Math.max(2, Math.floor(room / STEP) + 1);
const per = Math.ceil(count / slots);
const n = Math.ceil(count / per);
const gap = n > 1 ? Math.min(STEP, room / (n - 1)) : STEP;
const span = (n - 1) * gap;
const start = rail / 2 - span / 2;
return Array.from({ length: n }, (_, i) => ({
top: start + i * gap,
from: i * per,
to: Math.min(count - 1, (i + 1) * per - 1),
single: per === 1,
}));
}
/** How strongly a mark answers the focus: 1 at it, 0 beyond REACH. */
function pull(i: number, focus: number): number {
if (focus < 0) return 0;
const d = Math.abs(i - focus);
return d > REACH ? 0 : (Math.cos((Math.PI * d) / REACH) + 1) / 2;
}
/** Convert a pointer position from the screen-sized rectangle into the rail's
* layout coordinate system. CSS zoom changes the former but clientHeight and
* the mark tops stay in the latter, so subtracting the two directly drifts in
@@ -152,15 +84,39 @@ export function railLocalY(clientY: number, rectTop: number, rectHeight: number,
return (clientY - rectTop) * (railHeight / rectHeight);
}
// Where each mark sits in the scrolled content, as a fraction of its height: a
// mounted message by its own box, an unmounted one by its offset inside its
// block, which is mounted or a placeholder holding the block's real height.
function fractions(marks: RailMark[], total: number, root: HTMLElement, inner: HTMLElement): number[] {
const height = root.scrollHeight;
const chunks = inner.querySelectorAll<HTMLElement>(".chunk");
const base = root.getBoundingClientRect().top - root.scrollTop;
const boxes = [...chunks].map((c) => c.getBoundingClientRect());
const measured = height > 0 && marks.every((m) => (boxes[m.block]?.height ?? 0) > 0);
if (!measured) return marks.map((m) => m.at / Math.max(1, total - 1));
return marks.map((m) => {
const own = inner.querySelector(`[data-item="${CSS.escape(m.id)}"]`);
const box = own ? renderedBox(own as HTMLElement).getBoundingClientRect() : null;
if (box && box.height > 0) return (box.top - base) / height;
const block = boxes[m.block];
return (block.top - base + (m.of > 1 ? m.within / m.of : 0) * block.height) / height;
});
}
const StarGlyph = () => (
<svg className="srail-star" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.2l1.7 3.6 3.9.5-2.9 2.7.8 3.9L8 11l-3.5 1.9.8-3.9-2.9-2.7 3.9-.5z" /></svg>
);
export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier = 0, onLoadEarlier }: Props) {
const host = useRef<HTMLDivElement>(null);
const [tops, setTops] = useState<number[]>([]);
// Null while every question keeps its own line; a list once they have to
// share, because reading them apart has stopped working.
const [groups, setGroups] = useState<RailView[] | null>(null);
const [band, setBand] = useState<{ top: number; height: number } | null>(null);
// The drawn thing the pointer or the keys picked out; `keyed` when the keys did.
const [at, setAt] = useState(-1);
// Which folded run has been opened into its own questions. A run is one line
// standing for many; opening it lets the reader pick the question they meant.
const [keyed, setKeyed] = useState(false);
// Which run has been opened into its own messages. A run is one segment
// standing for many; opening it lets the reader pick the one they meant.
const [opened, setOpened] = useState<number | null>(null);
// The pointer crosses a gap between a run and the list beside it, so closing
// waits briefly instead of dropping the list the moment the line is left.
@@ -176,8 +132,6 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier
// Which mark the keys last walked to. Kept apart from `at`, which the pointer
// owns: hovering somewhere else must not move where the keys resume from.
const here = useRef(-1);
// Which questions the reader starred. Kept by message id so folding the rail
// around them cannot lose one.
const [stars, setStars] = useState<Set<string>>(() => loadBookmarks(scope));
const starScope = useRef(scope);
useEffect(() => {
@@ -203,26 +157,30 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier
return next;
});
}, [scope]);
// Rail height is read when the surface changes rather than on every streamed
// frame.
const starred = (m: RailMark | undefined) => m?.msgIndex !== undefined && stars.has(String(m.msgIndex));
const head = earlier > 0 && onLoadEarlier ? HEAD : 0;
const geom = useRef({ rail: 1 });
const follow = useCallback(() => {
const root = scroll.current;
if (root) setBand(viewBand(root.scrollTop, root.clientHeight, root.scrollHeight, geom.current.rail, head));
}, [scroll, head]);
const measure = useCallback(() => {
const root = scroll.current;
const inner = flow.current;
const box = host.current;
if (!root || !inner || !box) return;
const rail = root.clientHeight;
const rail = root?.clientHeight ?? 0;
// A rail with no height is one not laid out yet, not one every mark shares.
if (!root || !inner || !box || rail <= 0) return;
box.style.setProperty("--rail-h", rail + "px");
geom.current = { rail };
setTops(layout(marks.length, rail));
setGroups(cluster(marks.length, rail));
}, [marks, total, scroll, flow]);
setTops(place(fractions(marks, total, root, inner), rail, head));
follow();
}, [marks, total, scroll, flow, head, follow]);
// Deferred for the same reason the size observer below is: measure() reads
// clientHeight and scrollHeight, and running that inside the commit that just
// mounted a transcript forces a whole-document layout. One frame later the
// browser has done it anyway.
// A frame late: measure() reads layout, and inside the commit that mounted a
// transcript that read would force a whole-document layout of its own.
useEffect(() => {
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
@@ -232,10 +190,7 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier
const root = scroll.current;
const inner = flow.current;
if (!root || !inner) return;
// One measurement per frame, not one per mutation. Mounting a long
// transcript resizes the content once per block, and measure() reads
// clientHeight and scrollHeight — a forced layout apiece, then a state
// update that renders the rail again.
// One measurement per frame, not one per block that resized while mounting.
let raf = 0;
const ro = new ResizeObserver(() => {
if (raf) return;
@@ -246,17 +201,31 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier
});
ro.observe(inner);
ro.observe(root);
let moved = 0;
const onScroll = () => {
if (!moved) moved = requestAnimationFrame(() => { moved = 0; follow(); });
};
root.addEventListener("scroll", onScroll, { passive: true });
return () => {
if (raf) cancelAnimationFrame(raf);
if (moved) cancelAnimationFrame(moved);
ro.disconnect();
root.removeEventListener("scroll", onScroll);
};
}, [scroll, flow, measure]);
}, [scroll, flow, measure, follow]);
// Ctrl/⌘ + ↑/↓ walks the marks. Which one you are "at" is decided by the viewport,
// not by a cursor the rail would have to keep: the nearest mark above the top
// of the view is where you are, so the keys agree with what you can see.
// Until the first measurement every mark is drawn on its own, unplaced, so
// the marks exist to be named and focused from the first frame.
const view: RailView[] = useMemo(
() => (tops.length === marks.length ? fold(tops) : marks.map((_m, i) => ({ top: 0, bottom: 0, from: i, to: i, single: true }))),
[tops, marks],
);
// Ctrl/⌘ + ↑/↓ walks the marks. Until the keys have walked anywhere, where
// you are is the viewport: the mark nearest the top of what you can see.
useEffect(() => {
if (!bound) return;
let fade: ReturnType<typeof setTimeout> | undefined;
const onKey = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
const el = e.target as HTMLElement | null;
@@ -264,19 +233,12 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier
const root = scroll.current;
if (!root || tops.length === 0) return;
const up = e.key === "ArrowUp";
// Walk the marks themselves. The nearest starting point is derived from
// the same transcript fraction used by the overview ruler.
let pick: number;
if (here.current >= 0) {
pick = here.current + (up ? -1 : 1);
} else {
// Nothing walked yet: start from whichever mark the viewport is nearest,
// which is the one fraction of the transcript it is showing. Measured
// against where each message sits in the transcript, not against the
// overview track rather than any currently mounted card geometry.
const seen = Math.min(1, root.scrollTop / Math.max(1, root.scrollHeight - root.clientHeight));
const place = (i: number) => marks[i].at / Math.max(1, total);
const near = marks.reduce((best, _m, i) => (Math.abs(place(i) - seen) < Math.abs(place(best) - seen) ? i : best), 0);
const [seen] = place([root.scrollTop / Math.max(1, root.scrollHeight)], geom.current.rail, head);
const near = tops.reduce((best, top, i) => (Math.abs(top - seen) < Math.abs(tops[best] - seen) ? i : best), 0);
pick = up ? near : Math.min(near + 1, tops.length - 1);
}
if (pick < 0 || pick >= marks.length) return;
@@ -284,123 +246,88 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier
e.preventDefault();
onJump(marks[pick]);
const slot = view.findIndex((v) => pick >= v.from && pick <= v.to);
if (slot >= 0) {
setAt(slot);
setTimeout(() => setAt((v) => (v === slot ? -1 : v)), 1400);
}
if (slot < 0) return;
setAt(slot);
setKeyed(true);
clearTimeout(fade);
fade = setTimeout(() => { setAt((v) => (v === slot ? -1 : v)); setKeyed(false); }, 1400);
};
addEventListener("keydown", onKey);
return () => removeEventListener("keydown", onKey);
}, [bound, tops, marks, total, scroll, onJump]);
return () => { clearTimeout(fade); removeEventListener("keydown", onKey); };
}, [bound, tops, marks, view, head, scroll, onJump]);
const drawn = useMemo(() => {
if (tops.length !== marks.length) return marks.map((_m, i) => i);
const out: number[] = [];
let last = -Infinity;
tops.forEach((top, i) => {
if (top - last >= MIN_GAP || i === tops.length - 1) {
out.push(i);
last = top;
}
});
return out;
}, [tops, marks]);
// What is drawn: one line per question while the spacing holds, a folded run
// per line once it does not. `at` indexes this list either way. `groups` is a
// state slice, so after a rewind drops questions it can still name an index
// the shorter `marks` no longer has; the fold is filtered to what exists.
const view: RailView[] = useMemo(
() => (groups ?? drawn.map((i) => ({ top: tops[i] ?? 0, from: i, to: i, single: true })))
.filter((v) => v.from < marks.length),
[groups, drawn, tops, marks.length],
);
// The pointer's y decides which mark it means. Hitting a three-pixel line is
// not a thing anyone should have to do, and a mark that moved under the
// pointer to acknowledge the hover would take itself out from under it.
// The pointer's y decides which mark it means, so nobody has to hit a 2px
// line, and a line growing under the pointer cannot take itself out from under it.
const aim = (y: number) => {
let best = -1;
let dist = Infinity;
(groups ? groups.map((g) => g.top) : tops).forEach((top, i) => {
const d = Math.abs(top - y);
if (d < dist) {
dist = d;
best = i;
}
});
const next = dist <= 40 ? best : -1;
const next = nearest(view, y);
setAt(next);
// A folded run stands for several questions and cannot say which one the
// pointer means, so reaching it opens the run itself. Reaching a single
// question closes it again; passing over bare track leaves both alone.
setKeyed(false);
const hit = next >= 0 ? view[next] : null;
if (!hit) return;
hold();
setOpened(hit.single ? null : next);
};
if (marks.length === 0) return null;
const shown = opened === null && at >= 0 && at < view.length ? view[at] : null;
const head = shown ? marks[shown.from] : null;
if (marks.length < 3) return null;
const shown = opened === null && at >= 0 && at < view.length && view[at].single ? view[at] : null;
const peek = shown ? marks[shown.from] : null;
const run = opened !== null ? view[opened] : undefined;
const clamp = (y: number, edge: number) => Math.min(Math.max(y, edge), Math.max(edge, geom.current.rail - edge));
return (
<div className="railhost" ref={host} aria-hidden={undefined}>
{/* A paged conversation mounts only its tail. Without this the rail ends
where the loaded window begins and reads as a short conversation. */}
{earlier > 0 && onLoadEarlier && (
<button
type="button"
className="srail-earlier"
style={{ top: Math.max(6, (view[0]?.top ?? PAD) - 14) }}
aria-label={t("chat.n_more_messages_before_these", { n: earlier })}
title={t("chat.n_more_messages_before_these", { n: earlier })}
data-action="history.more"
onClick={onLoadEarlier}
/>
<div className="railhost" ref={host}>
{/* A paged conversation mounts only its tail; without this the rail
would read as the whole of a conversation that is mostly on disk. */}
{head > 0 && (
<button type="button" className="srail-earlier" data-action="history.more" onClick={onLoadEarlier}
aria-label={t("chat.n_more_messages_before_these", { n: earlier })}>
<StudioIcon name="arrow" />
{earlier}
<span className="srail-tip" aria-hidden="true">{t("chat.n_more_messages_before_these", { n: earlier })}</span>
</button>
)}
<nav
className="srail"
aria-label={t("chat.what_you_said")}
data-action-click="rail.jump"
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
aim(railLocalY(e.clientY, rect.top, rect.height, geom.current.rail));
}}
onMouseLeave={() => { setAt(-1); release(); }}
onClick={(e) => {
if (e.target !== e.currentTarget) return;
// `at` rather than `shown`: a folded run opens on hover now, which
// suppresses the peek, but a press on bare track must still land.
if (at >= 0 && at < view.length) onJump(marks[view[at].from]);
if (e.target !== e.currentTarget || at < 0 || at >= view.length) return;
const rect = e.currentTarget.getBoundingClientRect();
onJump(marks[within(tops, view[at], railLocalY(e.clientY, rect.top, rect.height, geom.current.rail))]);
}}
>
{band && <div className="srail-band" style={{ top: band.top, height: band.height }} />}
{view.map((v, i) => {
const m = marks[v.from];
const lit = pull(i, at);
const folded = !v.single;
// A folded run counts as starred when any question inside it is.
let starred = m.msgIndex !== undefined && stars.has(String(m.msgIndex));
if (!starred && folded) {
for (let k = v.from; k <= v.to; k++) {
const inner = marks[k].msgIndex;
if (inner !== undefined && stars.has(String(inner))) { starred = true; break; }
}
}
let lit = starred(m);
for (let k = v.from + 1; !lit && k <= v.to; k++) lit = starred(marks[k]);
const seen = band !== null && v.bottom >= band.top && v.top <= band.top + band.height;
const base = folded ? 12 : m.files > 0 ? 14 : 10;
const tall = Math.max(4, v.bottom - v.top);
return (
<button
key={folded ? `fold-${v.from}` : m.id}
type="button"
className="srail-m"
style={{ top: v.top }}
style={{ top: (v.top + v.bottom) / 2, ...(folded ? { height: tall + 8, marginTop: -(tall + 8) / 2 } : {}) }}
data-on={i === at ? "" : undefined}
data-keyed={i === at && keyed ? "" : undefined}
data-folded={folded ? "" : undefined}
data-starred={starred ? "" : undefined}
data-starred={lit ? "" : undefined}
data-seen={seen ? "" : undefined}
data-edits={!folded && m.files > 0 ? "" : undefined}
aria-label={folded
? t("chat.jump_to_questions_a_to", { a: v.from + 1, b: v.to + 1 })
: t("chat.jump_to_what_you_said", { text: m.text.slice(0, 40) })}
data-action-click="rail.jump"
data-action-contextmenu="rail.star"
data-action-keydown="rail.star"
title={m.msgIndex === undefined ? undefined : starred ? t("chat.right_click_to_remove_the") : t("chat.right_click_to_star_this")}
onFocus={(e) => { if (e.currentTarget.matches(":focus-visible")) setAt(i); }}
onBlur={() => setAt(-1)}
onClick={() => {
@@ -418,59 +345,30 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier
star(m.msgIndex);
}}
>
{/* 会动的是这条线,按钮本身不动 —— 它一动就会从指针底下跑掉。
长度和浓淡都是到焦点的距离的函数,静止时人人相等:尺寸原来
跟着这一轮碰过的文件数走,那让一列本该等价的入口长短不一,
而「碰了几个文件」这句话悬停时就在旁边说得清楚。 */}
<i style={{ "--lit": lit.toFixed(3) } as CSSProperties} />
<i style={{ "--w": `${base + (at >= 0 && !folded ? grow(Math.abs(i - at)) : 0)}px`, ...(folded ? { "--h": `${tall}px` } : {}) } as CSSProperties} />
</button>
);
})}
</nav>
{/* A folded run is one line standing for many, so a press on it can only
guess. Opening it first lets the reader pick the question they meant;
every row is one mark, and the run closes on the way out. */}
{opened !== null && view[opened] && !view[opened].single && (
<div
className="srail-sub"
style={{ top: Math.min(Math.max(view[opened].top, 60), Math.max(60, geom.current.rail - 60)) }}
onMouseEnter={hold}
onMouseLeave={release}
>
<div className="srail-subhd">{t("chat.questions_a_to_b", { a: view[opened].from + 1, b: view[opened].to + 1 })}</div>
{Array.from({ length: view[opened].to - view[opened].from + 1 }, (_, k) => {
const mk = marks[view[opened]!.from + k];
if (!mk) return null;
return (
<button
key={mk.id}
type="button"
className="srail-subrow"
data-action="rail.pick"
onClick={() => { setOpened(null); onJump(mk); }}
>
{mk.text.slice(0, 60)}
</button>
);
})}
{run && !run.single && (
<div className="srail-sub" style={{ top: clamp((run.top + run.bottom) / 2, 150) }} onMouseEnter={hold} onMouseLeave={release}>
<div className="srail-subhd">{t("chat.questions_a_to_b", { a: run.from + 1, b: run.to + 1 })}</div>
{marks.slice(run.from, run.to + 1).map((mk, k) => (
<button key={mk.id} type="button" className="srail-subrow" data-action="rail.pick" onClick={() => { setOpened(null); onJump(mk); }}>
<span className="n">{run.from + k + 1}</span>
<span className="tx">{mk.text.slice(0, 60)}</span>
{starred(mk) && <StarGlyph />}
</button>
))}
</div>
)}
{/* 贴着轨道两端的那几条,预览要收进容器里 —— 否则它会被裁掉一半 */}
{shown && (
<div
className="srail-peek"
style={{ top: Math.min(Math.max(shown.top, 44), Math.max(44, geom.current.rail - 44)) }}
role="tooltip"
>
{shown && peek && (
<div className="srail-peek" style={{ top: clamp(shown.top, 44) }} role="tooltip">
<div className="hd">
<span className="n">
{shown.single
? t("chat.your_message_n", { n: shown.from + 1 })
: t("chat.messages_a_to_b", { a: shown.from + 1, b: shown.to + 1 })}
</span>
{head && head.files > 0 && <span>· {t("chat.changed_n_files", { n: head.files })}</span>}
<span>{t("chat.your_message_n", { n: shown.from + 1 })}</span>
{peek.files > 0 && <><span className="dot">·</span><span>{t("chat.changed_n_files", { n: peek.files })}</span></>}
</div>
<div className="tx">{head?.text}</div>
<div className="tx">{peek.text}</div>
<div className="keys">{t("chat.click_to_jump_right_click")}</div>
</div>
)}
+4 -4
View File
@@ -41,7 +41,7 @@ import { Rail, type RailMark } from "./Rail";
import { railMarks } from "./railmarks";
import { StudioIcon } from "./StudioIcon";
import { LiveWork, useStartsOpen } from "../state/foldpref";
import { landingBox, renderedBox, useFindLanding, useFindPaint } from "./findland";
import { glide, landingBox, renderedBox, useFindLanding, useFindPaint } from "./findland";
import { ScrollJump } from "./ScrollJump";
import { landing as landingOf, type Direction } from "./jumpreach";
import { chunkText, heldBlocks, type Held } from "./chunk_text";
@@ -329,8 +329,7 @@ export function Transcript({ items, port, historyPaging, entering, onEntered, br
const ready = inner.querySelector<HTMLElement>(selector);
if (ready && then?.(ready, root)) return;
const chunk = inner.querySelectorAll<HTMLElement>(".chunk")[block];
if (ready) root.scrollTop = topOf(renderedBox(ready)) - clear;
else if (chunk) root.scrollTop = topOf(chunk) + into * chunk.offsetHeight - clear;
if (!ready && chunk) root.scrollTop = topOf(chunk) + into * chunk.offsetHeight - clear;
const settle = (tries: number, last = NaN) => {
if (mine !== landing.current || !inner.isConnected) return;
const found = inner.querySelector<HTMLElement>(selector);
@@ -350,7 +349,8 @@ export function Transcript({ items, port, historyPaging, entering, onEntered, br
}
if (tries > 0) requestAnimationFrame(() => settle(tries - 1));
};
requestAnimationFrame(() => settle(20));
if (ready) glide(root, topOf(renderedBox(ready)) - clear, () => mine === landing.current, () => settle(20));
else requestAnimationFrame(() => settle(20));
},
[scroll, flow],
);
@@ -5,7 +5,8 @@ import userEvent from "@testing-library/user-event";
import "../testkit";
import { ToolCard } from "./ToolCard";
import { JobsLiveContext } from "../JobRunView";
import type { JobEntry, Tool } from "../../port/port";
import type { JobEntry } from "../../port/port";
import type { Tool } from "../../port/wire";
afterEach(cleanup);
+22
View File
@@ -83,6 +83,28 @@ function reveal(row: HTMLElement, root: HTMLElement, finder: Finder, nth: number
return true;
}
const GLIDE_MS = 300;
/** Scrolls to `to` over GLIDE_MS, or at once when motion is reduced or there is
* nothing to cover. It stops when `live` turns false: a newer landing owns the scroll. */
export function glide(root: HTMLElement, to: number, live: () => boolean, done: () => void) {
const from = root.scrollTop;
if (Math.abs(to - from) < 1 || typeof matchMedia !== "function" || matchMedia("(prefers-reduced-motion: reduce)").matches) {
root.scrollTop = to;
done();
return;
}
const start = performance.now();
const step = (now: number) => {
if (!live()) return;
const k = Math.min(1, Math.max(0, now - start) / GLIDE_MS);
root.scrollTop = from + (to - from) * (1 - (1 - k) ** 3);
if (k < 1) requestAnimationFrame(step);
else done();
};
requestAnimationFrame(step);
}
/** What a landing scrolls to: the element with a box, and every fold around it
* opened, since a hit inside a folded group is one nobody can see. */
export function landingBox(el: HTMLElement): HTMLElement {
@@ -15,6 +15,8 @@ port.history = async () => [
{ role: "assistant", content: "第一段回答", msgIndex: 1 },
{ role: "user", content: "第二个问题", msgIndex: 2 },
{ role: "assistant", content: "第二段回答", msgIndex: 3 },
{ role: "user", content: "第三个问题", msgIndex: 4 },
{ role: "assistant", content: "第三段回答", msgIndex: 5 },
];
const pane = (rt: RuntimeView, port: AgentPort) => (
@@ -30,7 +32,7 @@ it.each(["full", "paged"])("a question starred before the first save stays with
localStorage.clear();
const reading: AgentPort = Object.assign(new MockPort(), { history: port.history });
if (mode === "paged") reading.historyPage = async () => ({
messages: await port.history(), toolContext: [], snapshot: "stars", start: 0, end: 4, total: 4, hasMoreBefore: false,
messages: await port.history(), toolContext: [], snapshot: "stars", start: 0, end: 6, total: 6, hasMoreBefore: false,
});
const unsaved = { id: "r1", root: "/w", name: "w" } as RuntimeView;
const view = render(pane(unsaved, reading));
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { fold, grow, nearest, PAD, place, viewBand, within } from "./railgeom";
describe("where the message rail draws each mark", () => {
it("spreads marks over the whole column by their place in the conversation, not bunched in the middle", () => {
expect(place([0, 0.5, 1], 300)).toEqual([PAD, 150, 300 - PAD]);
});
it("leaves room at the top for the earlier-messages marker", () => {
expect(place([0], 300, 34)[0]).toBe(PAD + 34);
});
it("draws marks apart while they can be told apart, and joins the crowded ones into runs", () => {
const view = fold([20, 40, 42, 44, 46, 80]);
expect(view.map((v) => [v.from, v.to, v.single])).toEqual([[0, 0, true], [1, 4, false], [5, 5, true]]);
expect(view[1]).toMatchObject({ top: 40, bottom: 46 });
});
it("breaks a crowd into several runs rather than one bar down the whole rail", () => {
const tops = Array.from({ length: 200 }, (_, i) => 20 + i);
const view = fold(tops);
expect(view.length).toBeGreaterThan(2);
expect(view.every((v) => v.bottom - v.top <= 56)).toBe(true);
expect(view.at(-1)!.to).toBe(199);
});
it("magnifies the pointed line most and its two neighbours on each side less", () => {
expect([0, 1, 2, 3, -1].map(grow)).toEqual([12, 6, 2, 0, 0]);
});
it("places the viewport on the same scale as the marks, and draws nothing when everything fits", () => {
expect(viewBand(0, 600, 600, 300)).toBeNull();
expect(viewBand(1200, 600, 2400, 300)).toEqual({ top: PAD + 0.5 * (300 - PAD * 2), height: 0.25 * (300 - PAD * 2) });
});
it("picks by height: a run under the pointer, and inside it the mark at that height", () => {
const tops = [20, 40, 42, 44, 46, 80];
const view = fold(tops);
expect(nearest(view, 43)).toBe(1);
expect(nearest(view, 75)).toBe(2);
expect(nearest(view, 200)).toBe(-1);
expect(within(tops, view[1], 44.4)).toBe(3);
});
});
+71
View File
@@ -0,0 +1,71 @@
/** One drawn thing on the message rail: a single mark, or a run of marks too
* close to tell apart, drawn as one segment from its first to its last. */
export interface RailView {
top: number;
bottom: number;
from: number;
to: number;
single: boolean;
}
export const PAD = 18;
// Room the earlier-messages marker takes above the first mark.
export const HEAD = 34;
// Closer than this, two lines are neither told apart by eye nor by pointer.
const CLUSTER_AT = 5;
// A run longer than this stops reading as one place in the conversation.
const MAX_RUN = 56;
const GROW = [12, 6, 2];
/** Where each mark sits, from its place in the whole conversation (0..1) to a
* y on a rail `rail` tall, with `head` reserved above the first. */
export function place(fractions: number[], rail: number, head = 0): number[] {
const room = Math.max(0, rail - PAD * 2 - head);
return fractions.map((f) => PAD + head + Math.min(1, Math.max(0, f)) * room);
}
export function fold(tops: number[]): RailView[] {
const out: RailView[] = [];
let i = 0;
while (i < tops.length) {
let j = i;
while (j + 1 < tops.length && tops[j + 1] - tops[j] < CLUSTER_AT && tops[j + 1] - tops[i] <= MAX_RUN) j++;
out.push({ top: tops[i], bottom: tops[j], from: i, to: j, single: i === j });
i = j + 1;
}
return out;
}
/** How much longer a line draws for being `d` drawn things from the pointer. */
export function grow(d: number): number {
return d >= 0 && d < GROW.length ? GROW[d] : 0;
}
/** The stretch of the rail the viewport is showing, or null when the whole
* conversation fits and there is nothing to place it within. */
export function viewBand(scrollTop: number, clientHeight: number, scrollHeight: number, rail: number, head = 0) {
if (scrollHeight <= clientHeight + 1) return null;
const [top, bottom] = place([scrollTop / scrollHeight, (scrollTop + clientHeight) / scrollHeight], rail, head);
return { top, height: Math.max(16, bottom - top) };
}
/** The drawn thing nearest y, or -1 when y is on bare track far from any. */
export function nearest(view: RailView[], y: number, reach = 40): number {
let best = -1;
let dist = Infinity;
view.forEach((v, i) => {
const d = y < v.top ? v.top - y : y > v.bottom ? y - v.bottom : 0;
if (d < dist) {
dist = d;
best = i;
}
});
return dist <= reach ? best : -1;
}
/** Inside a run, the mark whose own place is nearest y. */
export function within(tops: number[], v: RailView, y: number): number {
let best = v.from;
for (let k = v.from; k <= v.to; k++) if (Math.abs(tops[k] - y) < Math.abs(tops[best] - y)) best = k;
return best;
}
+5 -5
View File
@@ -6,8 +6,8 @@
"codeowners": 0,
"commented-code": 0,
"complexity": 666,
"css-important": 128,
"css-size": 9093,
"css-important": 127,
"css-size": 9037,
"doc-language": 0,
"doc-owner": 27,
"doc-prose": 1434,
@@ -77,12 +77,12 @@
"doc-prose": 13
},
"desktop/frontend-next/src/styles/app.css": {
"css-important": 25,
"css-size": 5400
"css-important": 24,
"css-size": 5352
},
"desktop/frontend-next/src/styles/studio.css": {
"css-important": 103,
"css-size": 3693
"css-size": 3685
},
"desktop/internal/winuninstall/registry_windows.go": {
"essay": 1