Merge pull request #14393 from xxoingr/fix/rail-star-before-first-save

fix(studio): a star placed before a conversation's first save stays with it
This commit is contained in:
YHH authored and GitHub committed 2026-10-24 12:04:48 -07:00
commit f96ec2f869
5 files changed
+103 -5

No files matched your search

+1 -1
View File
@@ -516,7 +516,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
<LiveWork.Provider value={live}>
<NewAnswerAnchor.Provider value={marker ? newAnswerAt : null}>
<Transcript port={port}
scope={rt.sessionPath || rt.id}
scope={rt.sessionPath}
onSettings={onSettings}
brought={brought}
foldWork={foldWork}
+43 -2
View File
@@ -99,10 +99,12 @@ describe("a star belongs to the question, not the row id", () => {
scrollHeight: { configurable: true, value: 2400 },
scrollTop: { configurable: true, writable: true, value: 0 },
});
return render(
const rail = (s: string) => (
<Rail marks={marks} total={marks.length} scroll={{ current: root }} flow={{ current: flow }}
onJump={vi.fn()} bound scope={scope} />,
onJump={vi.fn()} bound scope={s} />
);
const view = render(rail(scope));
return Object.assign(view, { rescope: (s: string) => view.rerender(rail(s)) });
};
it("keeps the star on the same question after a remount with fresh ids", async () => {
@@ -143,4 +145,43 @@ describe("a star belongs to the question, not the row id", () => {
await waitFor(() => expect(again.getAllByRole("button")[0].getAttribute("data-starred")).toBe(""));
expect(again.getAllByRole("button")[1].getAttribute("data-starred")).toBeNull();
});
it("keeps a star placed before the conversation's first save", async () => {
localStorage.clear();
const view = draw(two("i1", "i2"), "");
fireEvent.contextMenu(view.getAllByRole("button")[0]);
await waitFor(() => expect(view.getAllByRole("button")[0].getAttribute("data-starred")).toBe(""));
view.rescope("s1");
await waitFor(() => expect(view.getAllByRole("button")[0].getAttribute("data-starred")).toBe(""));
view.unmount();
const again = draw(two("j1", "j2"), "s1");
await waitFor(() => expect(again.getAllByRole("button")[0].getAttribute("data-starred")).toBe(""));
expect(again.getAllByRole("button")[1].getAttribute("data-starred")).toBeNull();
});
it("does not hand an unsaved conversation's stars to the next one", async () => {
localStorage.clear();
const first = draw(two("i1", "i2"), "");
fireEvent.contextMenu(first.getAllByRole("button")[0]);
await waitFor(() => expect(first.getAllByRole("button")[0].getAttribute("data-starred")).toBe(""));
first.unmount();
const next = draw(two("i1", "i2"), "");
expect(next.getAllByRole("button")[0].getAttribute("data-starred")).toBeNull();
});
it("opening a saved conversation from an unstarred draft keeps that conversation's stars", async () => {
localStorage.clear();
const saved = draw(two("i1", "i2"), "s1");
fireEvent.contextMenu(saved.getAllByRole("button")[1]);
await waitFor(() => expect(saved.getAllByRole("button")[1].getAttribute("data-starred")).toBe(""));
saved.unmount();
const view = draw(two("j1", "j2"), "");
view.rescope("s1");
await waitFor(() => expect(view.getAllByRole("button")[1].getAttribute("data-starred")).toBe(""));
expect(view.getAllByRole("button")[0].getAttribute("data-starred")).toBeNull();
});
});
+17 -2
View File
@@ -31,7 +31,8 @@ interface Props {
flow: RefObject<HTMLDivElement | null>;
onJump: (mark: RailMark) => void;
/** Which conversation these marks belong to. Stars are stored per conversation,
* because the same position in two conversations is two different questions. */
* because the same position in two conversations is two different questions.
* Empty until the conversation is saved: its stars are then kept in memory. */
scope: string;
// Only the transcript on screen answers the keys; a pane on another tab keeps
// its rail but must not fight for them.
@@ -70,6 +71,7 @@ function bookmarkKey(scope: string) {
}
function loadBookmarks(scope: string): Set<string> {
if (!scope) return new Set();
try {
const raw = localStorage.getItem(bookmarkKey(scope));
const list = raw ? (JSON.parse(raw) as unknown) : [];
@@ -80,6 +82,7 @@ function loadBookmarks(scope: string): Set<string> {
}
function saveBookmarks(scope: string, marks: Set<string>) {
if (!scope) return;
try {
localStorage.setItem(bookmarkKey(scope), JSON.stringify([...marks]));
} catch {
@@ -157,7 +160,19 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props
// 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));
useEffect(() => setStars(loadBookmarks(scope)), [scope]);
const starScope = useRef(scope);
useEffect(() => {
const from = starScope.current;
starScope.current = scope;
if (from === scope) return;
setStars((prev) => {
// The first save names the conversation the stars were placed in.
if (from || !scope || prev.size === 0) return loadBookmarks(scope);
const next = new Set([...loadBookmarks(scope), ...prev]);
saveBookmarks(scope, next);
return next;
});
}, [scope]);
const star = useCallback((msgIndex: number | undefined) => {
if (msgIndex === undefined) return;
const key = String(msgIndex);
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import "./testkit";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import type { RuntimeView } from "../port/hub";
afterEach(cleanup);
const port = new MockPort();
port.history = async () => [
{ role: "user", content: "第一个问题", msgIndex: 0 },
{ role: "assistant", content: "第一段回答", msgIndex: 1 },
{ role: "user", content: "第二个问题", msgIndex: 2 },
{ role: "assistant", content: "第二段回答", msgIndex: 3 },
];
const pane = (rt: RuntimeView) => (
<Pane port={port} rt={rt} title="w" active visible sideHost={null} side={false}
onFocus={() => {}} onReport={() => {}} onSessionChanged={() => {}} pulse={0} findPulse={0}
onSettings={() => {}} needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
theme="dark" dockW={560} dockMax={880} onDockW={() => {}} />
);
const firstMark = async () => (await screen.findAllByRole("button", { name: /定位到你说的/ }))[0];
it("a question starred before the first save stays starred under the saved conversation, and nowhere else", async () => {
localStorage.clear();
const unsaved = { id: "r1", root: "/w", name: "w" } as RuntimeView;
const view = render(pane(unsaved));
fireEvent.contextMenu(await firstMark());
await waitFor(async () => expect((await firstMark()).getAttribute("data-starred")).toBe(""));
view.rerender(pane({ ...unsaved, sessionPath: "/w/.reasonix/sessions/a.jsonl" }));
await waitFor(async () => expect((await firstMark()).getAttribute("data-starred")).toBe(""));
// Runtime ids restart at r1 every launch, so a star kept under one would
// reappear in whichever unsaved conversation that pane holds next.
expect(Object.keys(localStorage).filter((k) => k.endsWith(":r1"))).toEqual([]);
});
@@ -0,0 +1,2 @@
- 修复:新对话第一次保存前在右侧提问细条上加的书签,保存后不再消失;也不会在重启后出现在另一段还没保存的新对话里。关联 #14296。
- Fixed: a question starred in the rail before a new conversation is first saved stays starred once it is saved, and no longer reappears in another unsaved conversation after a restart. Refs #14296.