diff --git a/packages/web/e2e/draft.spec.mjs b/packages/web/e2e/draft.spec.mjs index 8d138bf..92ba191 100644 --- a/packages/web/e2e/draft.spec.mjs +++ b/packages/web/e2e/draft.spec.mjs @@ -201,12 +201,39 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses await expect(page.getByRole("textbox", { name: "Workspace" })).toHaveValue( new RegExp(`${wsLabel}$`), ); - await page.getByRole("button", { name: "上级目录" }).click(); + // Regression (workspace picker race): while a /dirs request is in flight the picker's rows + // are disabled, so a rapid double-click on "parent dir" must issue exactly ONE request and + // ascend exactly one level — previously both clicks fired an un-sequenced load and could + // relocate the browsing position. The response is gated on an explicit release (not a + // timeout) so the second click deterministically lands inside the loading window. + let releaseDirs; + const dirsGate = new Promise((resolve) => { + releaseDirs = resolve; + }); + let dirsRequests = 0; + const dirsRoute = (url) => url.pathname.endsWith("/dirs"); + const gateDirs = async (route) => { + dirsRequests += 1; + await dirsGate; + await route.continue(); + }; + await page.route(dirsRoute, gateDirs); + const upRow = page.getByRole("button", { name: "上级目录" }); + await upRow.click(); + // force: the row is disabled while loading, and a plain click would stall on Playwright's + // actionability wait instead of exercising the double-click; the disabled button swallows it. + await upRow.click({ force: true }); + releaseDirs(); + const parentLabel = basename(dirname(namedWs)); + await expect(page.getByRole("textbox", { name: "Workspace" })).toHaveValue( + new RegExp(`${parentLabel}$`), + ); await expect(page.getByRole("textbox", { name: "Workspace" })).not.toHaveValue( new RegExp(`${wsLabel}$`), ); + expect(dirsRequests, "double-click while loading fires a single /dirs request").toBe(1); + await page.unroute(dirsRoute, gateDirs); await page.getByRole("button", { name: "使用此目录" }).click(); - const parentLabel = basename(dirname(namedWs)); await expect(page.getByLabel("Workspace")).toContainText(parentLabel); await page.reload(); await expect(page.getByLabel("Workspace")).toContainText(parentLabel); diff --git a/packages/web/src/features/benchmark/benchmark-case-browser.tsx b/packages/web/src/features/benchmark/benchmark-case-browser.tsx index 8b8fc7b..58a3c6c 100644 --- a/packages/web/src/features/benchmark/benchmark-case-browser.tsx +++ b/packages/web/src/features/benchmark/benchmark-case-browser.tsx @@ -10,6 +10,7 @@ import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import * as api from "../../api/endpoints"; import { apiErrorText } from "../../lib/api-error"; +import { joinWorkspacePath } from "../../lib/file-path"; import { formatBytes } from "../../lib/format"; import { S } from "../../lib/strings"; import { SkeletonList } from "../../components/ui/skeleton"; @@ -77,10 +78,6 @@ function extOf(name: string): string { return index >= 0 ? name.slice(index + 1).toLowerCase() : name.toLowerCase(); } -function joinPath(dir: string, name: string): string { - return dir === "" ? name : `${dir}/${name}`; -} - function dirOf(filePath: string): string { return filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ""; } @@ -136,7 +133,12 @@ function MaterialGroup({ }: MaterialGroupProps) { const [open, setOpen] = useState(defaultOpen); const [path, setPath] = useState(""); - const [listing, setListing] = useState(null); + /** Bound to the path it was fetched for: entry targets join against `base`, so a click on a + * row that is momentarily stale (the fetch effect nulls the listing, but the state update + * commits one frame later) cannot compound segments onto an already-advanced `path`. */ + const [listing, setListing] = useState<{ base: string; res: WorkspaceFilesResponse } | null>( + null, + ); const [listError, setListError] = useState(null); const initialReadmeOpened = useRef(false); @@ -149,7 +151,7 @@ function MaterialGroup({ .listBenchmarkCaseFiles(projectId, agentId, benchmarkId, caseSummary.id, path, material) .then((data) => { if (cancelled) return; - setListing(data); + setListing({ base: path, res: data }); if (path === "" && !initialReadmeOpened.current) { initialReadmeOpened.current = true; const readme = data.entries.find( @@ -169,11 +171,13 @@ function MaterialGroup({ const crumbs = path === "" ? [] : path.split("/"); const openEntry = (entry: WorkspaceFileEntry) => { + if (listing === null) return; // rows only render out of a loaded listing + const target = joinWorkspacePath(listing.base, entry.name); if (entry.kind === "dir") { - setPath(joinPath(path, entry.name)); + setPath(target); return; } - onPreview(material, joinPath(path, entry.name)); + onPreview(material, target); }; return ( @@ -219,10 +223,10 @@ function MaterialGroup({ )} {listError &&

{listError}

} {!listing && !listError && } - {listing?.entries.length === 0 && ( + {listing?.res.entries.length === 0 && (

{S.files.empty}

)} - {listing?.entries.map((entry) => ( + {listing?.res.entries.map((entry) => ( @@ -711,8 +722,9 @@ export function WorkspaceBrowser({ / @@ -756,21 +768,28 @@ export function WorkspaceBrowser({

{error}

) : data === null ? ( - ) : data.entries.length === 0 ? ( + ) : data.res.entries.length === 0 ? (

{S.files.empty}

) : ( // No "up a level" row: going up a level is done via the toolbar breadcrumbs (root / any segment is clickable). -