Merge commit 'refs/pr12824' into takeover/12824

# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
#	desktop/frontend-next/src/ui/browser-missed-read.interaction.test.tsx
#	desktop/frontend-next/src/ui/browser-open.test.ts
#	desktop/frontend-next/src/ui/html-preview.interaction.test.tsx
#	desktop/frontend-next/src/ui/useColumnOpening.ts
This commit is contained in:
yhh committed 2026-10-12 08:21:06 -07:00
commit a81212d745
20 files changed
+799 -29

No files matched your search

+1
View File
@@ -500,6 +500,7 @@ export const ACTIONS: UIAction[] = [
{ id: "workbench.clear-search", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.folder", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.diff", kind: "view", target: "entity", proof: "interaction" },
{ id: "files.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
@@ -17,6 +17,7 @@ export const EN_WINDOW: Record<string, string> = {
"所选会话正在运行,停止后才能操作": "Selected sessions are running. Stop them before continuing.",
"正在处理所选会话…": "Processing selected sessions…",
"删除 {n} 个会话?": "Delete {n} sessions?",
"请先打开一个会话,再查看它的文件。": "Open a session first to browse its files.",
"运行指标": "Run metrics",
"时间线": "Timeline",
"定位": "Locate",
+14
View File
@@ -102,6 +102,7 @@ export function App({ hub }: { hub: HubPort }) {
const [dockLimit, setDockLimit] = useState(() => dockMax(document.body.clientWidth));
const [report, setReport] = useState<PaneReport>(NO_REPORT);
const [findPulse, setFindPulse] = useState(0);
const [filesRequests, setFilesRequests] = useState<Record<string, number>>({});
const [error, setError] = useState("");
// false = closed, true = open at its last section, a string = open there.
const [settings, setSettings] = useState<string | boolean>(false);
@@ -342,6 +343,16 @@ export function App({ hub }: { hub: HubPort }) {
void reloadRemotes();
}, [reloadRemotes]);
const openFiles = useCallback(() => {
if (!panePorts.has(active)) {
setError(t("请先打开一个会话,再查看它的文件。"));
return;
}
hidePrefs();
if (roomGaveUp("scene")) setRail(false);
setFilesRequests((requests) => ({ ...requests, [active]: (requests[active] ?? 0) + 1 }));
}, [active, panePorts, hidePrefs]);
const openPane = useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setActive, setTakeover, reloadPanes: refreshPanes, sw });
// Awaitable because deleting a conversation has to close its pane first and
@@ -577,6 +588,7 @@ export function App({ hub }: { hub: HubPort }) {
<Nav
at={settings === false ? null : settings === true ? "" : settings}
onGo={showPrefs}
onFiles={openFiles}
onHome={hidePrefs}
onRail={() => setRail((v) => !v)}
onFeedback={() => setFeedback(feedbackEntryTab(feedbackUnread))}
@@ -618,6 +630,7 @@ export function App({ hub }: { hub: HubPort }) {
accountUnread={accountUnread}
wallet={report.wallet}
onSettings={showPrefs}
onFiles={openFiles}
onFeedback={(tab) => setFeedback(tab ?? "send")}
feedbackUnread={feedbackUnread}
onFind={openFind}
@@ -670,6 +683,7 @@ export function App({ hub }: { hub: HubPort }) {
onSessionChanged={refreshPanes}
pulse={settingsPulse}
findPulse={findPulse}
filesPulse={filesRequests[rt.id] ?? 0}
alert={rt.id === active ? bar : undefined}
needsProject={needsProject}
onOpenProject={() => {
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { Nav } from "./Nav";
import { ICON, type Section } from "./prefsnav";
const props = { at: null, onGo: vi.fn(), onHome: vi.fn(), onRail: vi.fn(), onFeedback: vi.fn(), feedbackUnread: 0, shown: true };
const props = { at: null, onGo: vi.fn(), onFiles: vi.fn(), onHome: vi.fn(), onRail: vi.fn(), onFeedback: vi.fn(), feedbackUnread: 0, shown: true };
afterEach(cleanup);
@@ -13,7 +13,6 @@ const sectionMark = (section: Section) => render(<svg>{ICON[section]}</svg>).con
describe("the icon rail draws the mark of the section it opens", () => {
it.each([
["用量", "usage"],
["文件", "storage"],
["工具与权限", "tools"],
["扩展", "ext"],
["记忆", "memory"],
@@ -27,3 +26,14 @@ describe("the icon rail draws the mark of the section it opens", () => {
expect(btn?.querySelector("svg")?.innerHTML).toBe(sectionMark(section));
});
});
it("routes Files to the explorer without marking it as Storage", () => {
const { container } = render(<Nav {...props} at="storage" />);
const files = container.querySelector('button[aria-label="文件"]')!;
expect(files.hasAttribute("aria-current")).toBe(false);
expect(files.getAttribute("data-action")).toBe("files.open");
fireEvent.click(files);
expect(props.onFiles).toHaveBeenCalledOnce();
expect(props.onGo).not.toHaveBeenCalled();
});
+9 -10
View File
@@ -3,18 +3,16 @@ import { FeedbackBadge } from "./feedbackentry";
import { ICON, type Section } from "./prefsnav";
import { StudioIcon } from "./StudioIcon";
// An entry that opens a settings section draws that section's mark from prefsnav,
// so the rail and the page it opens never show two icons for one destination. Only
// the conversation item, which opens no section, keeps a mark of its own.
// Settings entries share their destination's mark with prefsnav.
const CHAT = "M3.2 4.4a1.2 1.2 0 0 1 1.2-1.2h7.2a1.2 1.2 0 0 1 1.2 1.2v5a1.2 1.2 0 0 1-1.2 1.2H7.2L4.4 13v-2.4a1.2 1.2 0 0 1-1.2-1.2Z";
type Mark = "chat" | Section;
type Mark = "chat" | "files" | Section;
// Every entry goes somewhere that exists. A rail of icons that open nothing is
// the cheapest thing to draw and the most expensive thing to keep.
const TO: [Mark, string, string | null][] = [
const TO: [Mark, string, string | null, string?][] = [
["chat", "会话", null],
["usage", "用量", "usage"],
["storage", "文件", "storage"],
["files", "文件", "files", "files.open"],
["tools", "工具与权限", "tools"],
["ext", "扩展", "ext"],
["memory", "记忆", "memory"],
@@ -29,6 +27,7 @@ const FOOT: [Mark, string, string, string][] = [
interface Props {
at: string | null;
onGo: (sec?: string) => void;
onFiles: () => void;
onHome: () => void;
/** The conversation item already shows the conversation, so pressing it again
* does what the rail beside it can: open or close the workspace column. */
@@ -39,7 +38,7 @@ interface Props {
shown: boolean;
}
export function Nav({ at, onGo, onHome, onRail, onFeedback, feedbackUnread, shown }: Props) {
export function Nav({ at, onGo, onFiles, onHome, onRail, onFeedback, feedbackUnread, shown }: Props) {
const btn = ([key, label, sec, action]: [Mark, string, string | null, string?]) => (
<button
key={key}
@@ -47,11 +46,11 @@ export function Nav({ at, onGo, onHome, onRail, onFeedback, feedbackUnread, show
data-action={action}
aria-label={t(label)}
aria-current={(sec ?? null) === at ? "page" : undefined}
onClick={() => (sec === null ? (at === null ? onRail() : onHome()) : onGo(sec || undefined))}
onClick={() => (key === "files" ? onFiles() : sec === null ? (at === null ? onRail() : onHome()) : onGo(sec || undefined))}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
{key === "files" ? <StudioIcon name="file" aria-hidden="true" /> : <svg viewBox="0 0 16 16" aria-hidden="true">
{key === "chat" ? <path d={CHAT} /> : ICON[key]}
</svg>
</svg>}
<span className="navhint" role="tooltip">{t(label)}</span>
</button>
);
+3 -1
View File
@@ -28,6 +28,7 @@ import { Metrics } from "./Metrics";
import { railOf } from "./panels/derive";
import { accountOf, useHidesAmounts, useWallet } from "./wallet";
import { PaneNav } from "./PaneNav";
import { useOnBump } from "./useOnBump";
import { useShowView } from "./showview";
import { useSubmit } from "./usesubmit";
import { useSurfaceSlots } from "./usesurfaceslots";
@@ -74,7 +75,7 @@ const totalsOf = (st: SessionStatus) => ({
incompleteReason: st.sessionCostQuote?.incompleteReason,
});
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, browserAsk = 0, onManualBrowser, alert }: PaneProps) {
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, filesPulse = 0, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, browserAsk = 0, onManualBrowser, alert }: PaneProps) {
const [s, dispatch] = useReducer(reduce, initialState);
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
@@ -298,6 +299,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
setRevealChanges((n) => n + 1);
showView("browser");
}, [showView]);
useOnBump(filesPulse, openChanges);
useEffect(() => { void loadChangedFilesPref(port); }, [port]);
useEffect(() => { void loadFoldWorkPref(port); }, [port]);
const foldWork = useFoldsTurnWork(port);
+6 -3
View File
@@ -57,6 +57,7 @@ interface Props {
accountUnread: string;
wallet: string;
onSettings: (section?: string) => void;
onFiles: () => void;
onFeedback: (tab?: "send" | "mine") => void;
// Reports with a new reply or a question waiting; the entry and the palette say so.
feedbackUnread: number;
@@ -102,6 +103,7 @@ export function Sidebar({
accountUnread,
wallet,
onSettings,
onFiles,
onFeedback,
feedbackUnread,
onFind,
@@ -149,7 +151,8 @@ export function Sidebar({
run: () => void onOpen({ root: newSessionRoot }).catch(onError) },
{ id: "find", label: t("在这段对话里查找"), icon: "search", keywords: "find search conversation 查找 搜索 对话",
keys: chord("F"), run: onFind },
{ id: "storage", label: t("文件"), icon: "file", keywords: "files storage 文件 存储",
{ id: "files", label: t("文件"), icon: "file", keywords: "files workspace explorer 文件 工作区", run: onFiles },
{ id: "storage", label: t("存储"), icon: "file", keywords: "storage disk data 存储 磁盘 数据",
run: () => onSettings("storage") },
{ id: "ext", label: t("工具与集成"), icon: "plug", keywords: "tools mcp skills 工具 集成",
run: () => onSettings("ext") },
@@ -168,7 +171,7 @@ export function Sidebar({
{ id: "settings", label: t("设置"), icon: "settings", keywords: "settings preferences 设置 偏好",
run: () => onSettings() },
],
[newSessionRoot, onOpen, onSettings, onFeedback, feedbackUnread, onFind, onError],
[newSessionRoot, onOpen, onFiles, onSettings, onFeedback, feedbackUnread, onFind, onError],
);
const sessionCount = tree.reduce(
(n, ws) => n + (ws.total === undefined ? ws.sessions.filter((session) => !session.archived).length : ws.total - (ws.archived ?? 0)),
@@ -231,7 +234,7 @@ export function Sidebar({
</button>
{!navShown && (
<div className="studio-quicknav">
<button data-action="settings.section" data-value="storage" onClick={() => onSettings("storage")}><span aria-hidden="true"><StudioIcon name="file" /></span><b>{t("文件")}</b></button>
<button data-action="files.open" onClick={onFiles}><span aria-hidden="true"><StudioIcon name="file" /></span><b>{t("文件")}</b></button>
<button data-action="settings.section" data-value="ext" onClick={() => onSettings("ext")}><span aria-hidden="true"><StudioIcon name="plug" /></span><b>{t("工具与集成")}</b><small>MCP · Skills</small></button>
<button data-action="settings.section" data-value="remote" onClick={() => onSettings("remote")}><span aria-hidden="true"><StudioIcon name="server" /></span><b>{t("运行环境")}</b><small>Local / SSH</small></button>
</div>
@@ -270,7 +270,7 @@ export function WorkbenchPanel({
const dropBlankStart = useCallback(() => setBrowsers((open) => open.filter((id) => !!visited.current[id])), []);
const agentNow = useRef(agentTarget);
agentNow.current = agentTarget;
useColumnOpening({ manual, browserAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
useColumnOpening({ manual, browserAsk, filesAsk: revealChanges, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
// A page that has just appeared is the one somebody wants to see, whoever
// opened it: the agent's popup, or a link the person followed, which opens
// beside the agent's tab without becoming the one it acts on.
@@ -43,3 +43,39 @@ it.each(['failed-read', 'stream-gap'].flatMap(missed => ['same-url', 'changed-ur
expect(document.querySelector('[data-target="browser:target-b"]')?.closest('[role="tab"]')?.getAttribute('aria-selected')).toBe('true');
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it.each(['failed-read', 'stream-gap'].flatMap(missed => ['same-url', 'changed-url', 'deactivation'].flatMap(activity => ['before', 'after'].map(order => [missed, activity, order]))))('follows only later activity after %s (%s, response %s event)', async (missed, activity, order) => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const port = new MockPort();
const first = { id: 'a', target: 'target-a', url: 'https://first.example/', title: 'First', active: true };
const opened = { id: 'b', target: 'target-b', url: 'https://opened.example/', title: 'Opened', active: activity === 'deactivation' };
let emit!: (event: WireEvent) => void, gap!: () => void, complete!: (tab: typeof opened) => void;
const subscribe = port.subscribe.bind(port);
vi.spyOn(port, 'subscribe').mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; gap = onGap!; return subscribe(onEvent, onGap, bootstrap); });
const reads = vi.spyOn(port, 'browserTabs').mockResolvedValue(activity === 'deactivation' ? [] : [first]);
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { complete = done; }));
const pending = openPage(port, opened.url);
function Harness({filesPulse}: {filesPulse:number}) {
const [manual, setManual] = useState(true);
return <Pane {...props} rt={{id:'missed-read',base:'',root:'/w',name:'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual} />;
}
const {rerender} = render(<Harness filesPulse={0} />);
await waitFor(() => expect(reads).toHaveBeenCalledOnce());
await act(async () => {});
rerender(<Harness filesPulse={1} />);
const choiceShown = () => document.querySelector('.workbench-body')!.hasAttribute('data-files');
expect(choiceShown()).toBe(true);
if (missed === 'failed-read') {
reads.mockRejectedValueOnce(new Error('tab read failed'));
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
} else await act(async () => { gap(); });
if (order === 'before') await act(async () => { complete(opened); await pending; });
expect(choiceShown()).toBe(true);
const later = {...opened, active:activity !== 'deactivation', url:activity === 'changed-url' ? opened.url+'later' : opened.url};
reads.mockResolvedValue([{...first,active:activity === 'deactivation'},later]);
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
if (order === 'after') await act(async () => { complete(opened); await pending; });
expect(choiceShown()).toBe(false);
expect(document.querySelector(`[data-target="browser:${activity === 'deactivation' ? first.target : opened.target}"]`)?.closest('[role="tab"]')?.getAttribute('aria-selected')).toBe('true');
expect(unseenPageOpenings(port)).toHaveLength(0);
});
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import "./testkit";
import { App } from "./App";
import { MockHub } from "../port/mock_hub";
import { boot, STORAGE } from "../i18n";
import { refresh } from "./viewport";
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
localStorage.setItem(STORAGE, "zh");
boot();
vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(1440);
vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900);
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
refresh();
});
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); sessionStorage.clear(); });
async function mount(empty = false) {
const hub = new MockHub();
const runtime = empty ? null : await hub.open({ root: "~/projects/my-website", sessionPath: "/sessions/site.jsonl" });
if (empty) await hub.close("r1");
const ports = (await hub.runtimes()).map((rt) => hub.portFor(rt));
for (const port of ports) {
port.providerSetup = async () => null;
port.welcomeSeen = async () => true;
vi.spyOn(port, "browserOpen");
}
if (runtime) sessionStorage.setItem("rx-active-pane", runtime.id);
const open = vi.spyOn(hub, "open");
await act(async () => { render(<App hub={hub} />); });
await waitFor(() => expect(document.querySelector(".rail")).toBeTruthy());
return { ports, open };
}
const focused = () => document.querySelector<HTMLElement>(".pane[data-active]")!;
const filesShown = (pane = focused()) => pane.querySelector(".workbench-body")!.hasAttribute("data-files");
async function palette(query: string) {
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
await userEvent.type(await screen.findByRole("textbox", { name: "搜索会话或操作" }), query);
await userEvent.keyboard("{Enter}");
}
it.each(["rail", "sidebar", "palette"])("opens only the focused pane's files from %s and handles repeated navigation", async (entry) => {
if (entry === "sidebar") localStorage.setItem("rx-nav-rail", "off");
const { ports } = await mount();
const openFiles = async () => {
if (entry === "palette") return palette("文件");
const scope = document.querySelector<HTMLElement>(entry === "rail" ? ".nav" : ".studio-quicknav")!;
await userEvent.click(within(scope).getByRole("button", { name: "文件" }));
};
await openFiles();
await waitFor(() => expect(filesShown()).toBe(true));
expect(document.querySelector(".app")!.getAttribute("data-browser")).toBe("on");
expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(false);
const background = document.querySelector<HTMLElement>(".pane[data-off]")!;
expect(filesShown(background)).toBe(false);
expect(focused().querySelectorAll(".workbench-tab")).toHaveLength(0);
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
expect(filesShown()).toBe(false);
await openFiles();
expect(filesShown()).toBe(true);
expect(filesShown(background)).toBe(false);
expect(screen.queryByRole("dialog", { name: "搜索与快捷操作" })).toBeNull();
for (const port of ports) expect(port.browserOpen).not.toHaveBeenCalled();
});
it("leaves Storage in settings and dismisses settings when Files is chosen", async () => {
await mount();
await palette("storage");
await waitFor(() => expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(true));
await waitFor(() => expect(document.querySelector("#set-storage")).toBeTruthy());
await palette("文件");
await waitFor(() => expect(filesShown()).toBe(true));
expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(false);
});
it("closes the phone-width sidebar drawer after choosing files", async () => {
vi.mocked(Object.getOwnPropertyDescriptor(document.body, "clientWidth")!.get!).mockReturnValue(390);
refresh();
await mount();
fireEvent.click(document.querySelector('[data-action="chrome.rail"]')!);
expect(document.querySelector(".app")!.getAttribute("data-rail")).toBe("on");
fireEvent.click(document.querySelector(".studio-quicknav button")!);
await waitFor(() => expect(filesShown()).toBe(true));
expect(document.querySelector(".app")!.getAttribute("data-rail")).toBe("off");
});
it("does not open an arbitrary project when no session is open", async () => {
const { open } = await mount(true);
await palette("文件");
expect(open).not.toHaveBeenCalled();
expect(document.querySelector(".errbar")!.textContent).toContain("请先打开一个会话");
expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(false);
});
it("does not replay a Files request into a newly focused session", async () => {
await mount();
await palette("文件");
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
const previous = focused();
fireEvent.click(document.querySelector('.ptab[data-pane="r1"]')!);
expect(focused()).not.toBe(previous);
expect(filesShown()).toBe(false);
await palette("文件");
expect(filesShown()).toBe(true);
expect(filesShown(previous)).toBe(false);
fireEvent.click(document.querySelector('.ptab[data-pane="r2"]')!);
expect(filesShown()).toBe(false);
});
@@ -7,7 +7,7 @@ import * as hostModule from "../port/host";
import type { BrowserTab } from "../port/port";
import { SsePort } from "../port/sse";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { unseenPageOpenings } from "./browser_open";
import { openPage, unseenPageOpenings } from "./browser_open";
const ROOT = "/ws/site";
const page = (over: Partial<BrowserTab> = {}): BrowserTab => ({
@@ -47,6 +47,57 @@ afterEach(() => {
vi.unstubAllGlobals();
});
it.each([["before", false], ["before", true], ["after", false], ["after", true]])("keeps newer Files intent through a failed preview (event %s response, mixed=%s)", async (order, mixed) => {
const user = userEvent.setup();
const { port, open } = setup();
let reply!: (response: Response) => void;
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>((resolve) => { reply = resolve; })));
const wire = new SsePort("", "r1");
open.mockImplementation((url) => wire.browserOpen(url, true));
const agent = page({ id: "agent", target: "ta", url: "https://example.com", title: "Agent", active: true });
const fresh = page({ id: "fresh", target: "tf", url: "https://example.com/fresh", title: "Fresh" });
const { container, rerender } = render(panel(port, { tabs: [agent] }));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
await user.click(container.querySelector('[data-action="workbench.files"]')!);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
const tabs = mixed ? [agent, fresh, page()] : [agent, page()];
if (order === "before") {
rerender(panel(port, { tabs }));
expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull();
}
await act(async () => reply(new Response(JSON.stringify({ code: "browser.open_failed", params: { tab: page().id, error: "net::ERR_FILE_NOT_FOUND" } }), { status: 400 })));
if (order === "after") rerender(panel(port, { tabs }));
await waitFor(() => expect(unseenPageOpenings(port)).toHaveLength(0));
expect(screen.getByRole("tab", { name: "Landing" }).getAttribute("aria-selected")).toBe("false");
await waitFor(() => expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(!mixed));
if (mixed) expect(screen.getByRole("tab", { name: "Fresh" }).getAttribute("aria-selected")).toBe("true");
rerender(panel(port, { tabs: [{ ...agent, active: false }, page({ active: true, url: "https://example.com/reused" })] }));
await waitFor(() => expect(screen.getByRole("tab", { name: "Landing" }).getAttribute("aria-selected")).toBe("true"));
});
it("keeps a superseded successful user tab owned while a separate preview claim hides it", async () => {
const user = userEvent.setup();
const { port, open } = setup();
let previewDone!: (tab: BrowserTab) => void;
let userDone!: (tab: BrowserTab) => void;
open.mockReturnValueOnce(new Promise((resolve) => { previewDone = resolve; }));
open.mockReturnValueOnce(new Promise((resolve) => { userDone = resolve; }));
const userTab = page({ id: "user", target: "tu", url: "https://example.com", title: "User page" });
const { container, rerender } = render(panel(port));
await user.click(await screen.findByRole("button", { name: "index.html" }));
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
const pending = openPage(port, userTab.url);
await user.click(container.querySelector('[data-action="workbench.files"]')!);
await act(async () => { userDone(userTab); await pending; });
rerender(panel(port, { tabs: [userTab, page()] }));
expect(screen.queryByRole("tab", { name: "User page" })).toBeNull();
await act(async () => previewDone(page()));
await waitFor(() => expect(unseenPageOpenings(port)).toHaveLength(0));
expect(screen.getByRole("tab", { name: "User page" }).getAttribute("aria-selected")).toBe("false");
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
describe("opening an HTML file from the explorer", () => {
it.each(["Files", "launcher"])("does not start a hidden preview after a late file read and newer %s choice", async (choice) => {
const user = userEvent.setup();
@@ -108,6 +159,7 @@ describe("opening an HTML file from the explorer", () => {
await waitFor(() => expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy());
expect(screen.getByRole("button", { name: "预览" }).getAttribute("aria-pressed")).toBe("true");
expect(document.querySelector(".cm-content")).toBeNull();
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it("does not turn the page it opened for the file into a second tab", async () => {
@@ -126,6 +178,7 @@ describe("opening an HTML file from the explorer", () => {
expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull();
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["index.html"]);
expect(screen.getByRole("tab", { name: "index.html" }).getAttribute("aria-selected")).toBe("true");
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it("opens from the keyboard with Enter on the focused row", async () => {
@@ -142,4 +142,20 @@ describe("a pane's view", () => {
probe.unmount();
expect(all.rt.id).toBe("p1");
});
it("leaves analysis for an external files request and does not replay it after a takeover", async () => {
const onManualBrowser = vi.fn();
const { send, view, all } = mount("p1", { onManualBrowser });
send({ kind: "turn_started" });
fireEvent.click(await screen.findByRole("tab", { name: /运行分析/ }));
view.rerender(<Pane {...all} filesPulse={1} />);
expect(selected()).toBe("flow");
expect(onManualBrowser).toHaveBeenLastCalledWith(true);
expect(document.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
onManualBrowser.mockClear();
view.rerender(<Pane key="p1:1" {...all} filesPulse={1} />);
expect(onManualBrowser).not.toHaveBeenCalled();
expect(document.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
});
});
@@ -42,6 +42,7 @@ export interface PaneProps {
// pane keeps reporting the posture it had when it opened.
pulse: number;
findPulse: number;
filesPulse?: number;
onSettings: (section?: string) => void;
// Open a session this pane made, beside it: a branch is its own conversation.
onOpenSession?: (path: string) => void;
@@ -34,6 +34,7 @@ function draw(collapsed = false) {
return <>
{error && <div role="alert">{error}</div>}
<Sidebar
onFiles={vi.fn()}
hub={hub} collapsed={collapsed} navShown={false} paging={NO_PAGING} tree={[]} treeRead runtimes={[]} runs={{}} active="remote-pane"
activeWorkspace={undefined} liveIds={() => []} pinned={new Set()} onPin={() => {}}
remotes={null} remoteTrees={{}} reloadRemotes={async () => {}} reloadRemoteTrees={async () => {}}
@@ -8,15 +8,16 @@ interface Opening {
manual: boolean;
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
browserAsk: number;
filesAsk: number;
port: AgentPort;
agentNow: MutableRefObject<string>;
dropBlankStart: () => void;
pick: (key: string) => void;
follow: (key: string) => void;
manualSelection: string;
current: () => () => boolean;
setFailed: Dispatch<SetStateAction<string>>;
setBrowsers: Dispatch<SetStateAction<string[]>>;
manualSelection: string;
}
/** What the workbench column does when it is opened. It follows the agent's page,
@@ -24,19 +25,24 @@ interface Opening {
* its launcher until a browser is asked for. The title bar's Browser button
* asks for one itself: each press is one ask, and a page already on its way is
* shared rather than requested twice. */
export function useColumnOpening({ manual, browserAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection }: Opening) {
export function useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow, dropBlankStart, pick, follow, manualSelection, current, setFailed, setBrowsers }: Opening) {
const filesRequested = useRef(filesAsk);
useEffect(() => {
if (!manual) return;
const files = filesRequested.current !== filesAsk;
filesRequested.current = filesAsk;
if (!manual || files) return;
if (agentNow.current) {
dropBlankStart();
follow(`browser:${agentNow.current}`);
return;
}
const valid = current();
let alive = true;
pageInFlight(port)
?.then((tab) => { if (valid()) follow(`browser:${tab.target}`); })
.catch((e) => { if (valid()) setFailed(reason(e)); });
}, [manual, agentNow, dropBlankStart, follow, current, port, setFailed]);
?.then((tab) => { if (alive && valid()) follow(`browser:${tab.target}`); })
.catch((e) => { if (alive && valid()) setFailed(reason(e)); });
return () => { alive = false; };
}, [manual, filesAsk, agentNow, dropBlankStart, follow, port, setFailed, current]);
const asked = useRef(browserAsk);
useEffect(() => {
@@ -52,12 +58,13 @@ export function useColumnOpening({ manual, browserAsk, port, agentNow, dropBlank
// by any site sending X-Frame-Options or frame-ancestors, as many do.
if (host().drawsBrowserViews()) {
const valid = current();
let alive = true;
pageForEmptyColumn(port)
.then((tab) => { if (valid()) pick(`browser:${tab.target}`); })
.catch((e) => { if (valid()) setFailed(reason(e)); });
return;
.then((tab) => { if (alive && valid()) pick(`browser:${tab.target}`); })
.catch((e) => { if (alive && valid()) setFailed(reason(e)); });
return () => { alive = false; };
}
setBrowsers((open) => (open.length ? open : ["b0"]));
pick(manualSelection);
}, [browserAsk, manual, agentNow, dropBlankStart, pick, current, port, setFailed, setBrowsers, manualSelection]);
}, [browserAsk, filesAsk, manual, agentNow, dropBlankStart, pick, port, setFailed, setBrowsers, manualSelection, current]);
}
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import './testkit';
import { WorkbenchPanel } from './WorkbenchPanel';
import { MockPort } from '../port/mock';
import { host } from '../port/host';
import { openPage, unseenPageOpenings } from './browser_open';
const page = { id: 'b1', target: 't1', url: 'https://example.com', title: 'Existing page', active: true };
const props = { scheme: 'dark' as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
const filesShown = () => document.querySelector('.workbench-body')!.hasAttribute('data-files');
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
it('keeps Files visible when late browser tab list announces older followed link', async () => {
const port = new MockPort();
let resolve!: (v: typeof page) => void;
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { resolve = done; }));
const pending = openPage(port, page.url);
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
expect(filesShown()).toBe(true);
await act(async () => { resolve(page); await pending; });
expect(filesShown()).toBe(true);
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual revealChanges={1} />);
expect(filesShown()).toBe(true);
});
it.each([['workbench.launch-browser'], ['workbench.new', 'workbench.launch-browser']])('keeps Files visible when the browser opened by %j finishes', async (...actions) => {
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
const port = new MockPort();
let resolve!: (v: typeof page) => void;
const pending = new Promise<typeof page>(done => { resolve = done; });
vi.spyOn(port, 'browserOpen').mockReturnValue(pending);
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
for (const action of actions) fireEvent.click(document.querySelector(`[data-action="${action}"]`)!);
expect(port.browserOpen).toHaveBeenCalledOnce();
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
expect(filesShown()).toBe(true);
await act(async () => { resolve(page); await pending; });
expect(filesShown()).toBe(true);
});
import { useState } from 'react';
import { waitFor } from '@testing-library/react';
import { Pane } from './Pane';
import type { WireEvent } from '../port/wire';
const paneProps = { title: 'w', active: true, visible: true, sideHost: null, side: false, onFocus() {}, onReport() {}, onSessionChanged() {}, pulse: 0, findPulse: 0, onSettings() {}, needsProject: false, onOpenProject() {}, onKeepHere() {}, theme: 'dark' as const, dockW: 560, dockMax: 880, onDockW() {} };
it.each(['before', 'after'])('keeps Files visible when Pane receives browser_tabs_changed %s the response', async (order) => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const port = new MockPort();
let emit!: (event: WireEvent) => void;
const subscribe = port.subscribe.bind(port);
vi.spyOn(port, 'subscribe').mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; return subscribe(onEvent, onGap, bootstrap); });
const readTabs = vi.spyOn(port, 'browserTabs').mockResolvedValue([]);
let resolve!: (v: typeof page) => void;
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { resolve = done; }));
const pending = openPage(port, page.url);
function Harness({filesPulse}: {filesPulse: number}) {
const [manual, setManual] = useState(true);
return <Pane {...paneProps} rt={{id: 'event-pane', base: '', root: '/w', name: 'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual} />;
}
const { rerender } = render(<Harness filesPulse={0} />);
await waitFor(() => expect(readTabs).toHaveBeenCalledTimes(1));
await act(async () => {});
rerender(<Harness filesPulse={1} />);
expect(filesShown()).toBe(true);
if (order === 'after') await act(async () => { resolve(page); await pending; });
expect(filesShown()).toBe(true);
readTabs.mockResolvedValue([page]);
await act(async () => { emit({ kind: 'browser_tabs_changed' } as WireEvent); });
expect(readTabs).toHaveBeenCalledTimes(2);
if (order === 'before') await act(async () => { resolve(page); await pending; });
expect(filesShown()).toBe(true);
});
it.each([false, true])('ignores superseded user opens while following a new agent page (same batch=%s)', async (together) => {
const port = new MockPort();
const resolve: ((tab: typeof page) => void)[] = [];
vi.spyOn(port, 'browserOpen').mockImplementation(() => new Promise((done) => { resolve.push(done); }));
const first = openPage(port, page.url);
const second = openPage(port, 'https://example.org');
const later = { ...page, id: 'b2', target: 't2', url: 'https://example.org' };
const agent = { ...page, id: 'b3', target: 't3', url: 'https://example.net', title: 'New agent page' };
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
await act(async () => { resolve[0](page); resolve[1](later); await Promise.all([first, second]); });
if (!together) {
rerender(<WorkbenchPanel {...props} port={port} tabs={[page, later]} manual revealChanges={1} />);
await act(async () => {});
expect(filesShown()).toBe(true);
}
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent, page, later]} manual revealChanges={1} />);
await waitFor(() => expect(filesShown()).toBe(false));
expect(document.querySelector('[data-target="browser:t3"]')?.closest('[role="tab"]')?.getAttribute('aria-selected')).toBe('true');
});
it.each(['before', 'after', 'agent-before-response', 'canceled-final-read', 'stale-blank', 'stale-intermediate', 'canceled-before-response'])('reconciles an early blank tab and final navigation (order=%s)', async (order) => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const port = new MockPort();
let emit!: (event: WireEvent) => void, resolve!: (tab: typeof page) => void;
const subscribe = port.subscribe.bind(port);
vi.spyOn(port, 'subscribe').mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; return subscribe(onEvent, onGap, bootstrap); });
const readTabs = vi.spyOn(port, 'browserTabs').mockResolvedValue([]);
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { resolve = done; }));
const pending = openPage(port, page.url);
function Harness({filesPulse}: {filesPulse: number}) {
const [manual, setManual] = useState(true);
return <Pane {...paneProps} rt={{id: 'navigation-pane', base: '', root: '/w', name: 'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual} />;
}
const {rerender} = render(<Harness filesPulse={0} />);
await waitFor(() => expect(readTabs).toHaveBeenCalledOnce());
const tabsChanged = async (tabs: typeof page[]) => {
readTabs.mockResolvedValue(tabs);
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
};
await tabsChanged([{...page, url:'about:blank'}]);
rerender(<Harness filesPulse={1} />);
expect(filesShown()).toBe(true);
if (order.startsWith('stale-')) {
let readStale!: (tabs: typeof page[]) => void;
readTabs.mockReturnValueOnce(new Promise(done => {readStale=done;}));
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
readTabs.mockResolvedValue([page]);
await act(async () => { resolve(page); await pending; });
await act(async () => { readStale([{...page,url:order === 'stale-blank' ? 'about:blank' : page.url+'/intermediate'}]); });
expect(filesShown()).toBe(true);
}
if (order === 'after' || order === 'canceled-final-read') {
await act(async () => { resolve(page); await pending; });
}
if (order === 'canceled-final-read' || order === 'canceled-before-response') {
let readFinal!: (tabs: typeof page[]) => void;
readTabs.mockReturnValueOnce(new Promise(done => {readFinal=done;}));
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
await tabsChanged([{...page,url:page.url+'/newer'}]);
await act(async () => { readFinal([page]); });
} else await tabsChanged([page]);
if (order === 'agent-before-response') await tabsChanged([{...page,url:page.url+'/agent-navigation'}]);
if (order !== 'after') await act(async () => { resolve(page); await pending; });
expect(filesShown()).toBe(order !== 'agent-before-response' && order !== 'canceled-final-read' && order !== 'canceled-before-response');
expect(unseenPageOpenings(port)).toHaveLength(0);
await tabsChanged([{...page, url:page.url+'/agent-navigation'}]);
await waitFor(() => expect(filesShown()).toBe(false));
});
it('does not let an older ambiguous follow outrun a newer genuine agent tab in the same response wait', async () => {
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue(null);
const port = new MockPort();
let emit!: (event:WireEvent)=>void, complete!: (tab:typeof page)=>void;
const subscribe=port.subscribe.bind(port);
vi.spyOn(port,'subscribe').mockImplementation((onEvent,onGap,bootstrap)=>{emit=onEvent;return subscribe(onEvent,onGap,bootstrap);});
const tabs=vi.spyOn(port,'browserTabs').mockResolvedValue([]);
vi.spyOn(port,'browserOpen').mockReturnValue(new Promise(resolve=>{complete=resolve;}));
const pending=openPage(port,page.url);
function Harness({filesPulse}:{filesPulse:number}) {
const [manual,setManual]=useState(true);
return <Pane {...paneProps} rt={{id:'newer-agent-review',base:'',root:'/w',name:'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual}/>;
}
const {rerender}=render(<Harness filesPulse={0}/>);
await waitFor(()=>expect(tabs).toHaveBeenCalledOnce());
tabs.mockResolvedValue([{...page,url:'about:blank'}]);
await act(async()=>{emit({kind:'browser_tabs_changed'} as WireEvent);});
rerender(<Harness filesPulse={1}/>);
const later={...page,url:'https://genuine-later.example/'};
tabs.mockResolvedValue([later]);
await act(async()=>{emit({kind:'browser_tabs_changed'} as WireEvent);});
const newest={...page,id:'b2',target:'t2',url:'https://newest-agent.example/',title:'Newest agent'};
tabs.mockResolvedValue([{...later,active:false},newest]);
await act(async()=>{emit({kind:'browser_tabs_changed'} as WireEvent);});
let verify!: (value:typeof page[])=>void;
tabs.mockReturnValueOnce(new Promise(resolve=>{verify=resolve;}));
await act(async()=>{complete(page);await pending;});
const selected=()=>document.querySelector('[data-target="browser:t2"]')?.closest('[role="tab"]')?.getAttribute('aria-selected');
expect(selected()).toBe('true');
await act(async()=>{verify([{...later,active:false},newest]);});
expect(selected()).toBe('true');
});
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { MockPort } from "../port/mock";
import { openPage } from "./browser_open";
const page = { id: "b1", target: "t1", url: "https://example.com", title: "User page", active: false };
const other = { ...page, id: "b2", target: "t2", title: "Agent page", active: true };
const props = { scheme: "dark" as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
const filesShown = () => document.querySelector(".workbench-body")!.hasAttribute("data-files");
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
it.each([
["navigation", "before"], ["navigation", "after"],
["activation", "before"], ["activation", "after"],
])("follows later agent %s of a superseded user tab (event %s response)", async (reuse, order) => {
const port = new MockPort();
let resolve!: (tab: typeof page) => void;
vi.spyOn(port, "browserOpen").mockReturnValue(new Promise((done) => { resolve = done; }));
const pending = openPage(port, page.url);
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[other]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[other]} manual revealChanges={1} />);
const first = { ...page, active: reuse === "navigation" };
const agent = { ...other, active: !first.active };
if (order === "after") await act(async () => { resolve(first); await pending; });
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent, first]} manual revealChanges={1} />);
if (order === "before") await act(async () => { resolve(first); await pending; });
await act(async () => {});
expect(filesShown()).toBe(true);
const reused = { ...first, active: true, url: reuse === "navigation" ? "https://example.com/next" : first.url };
rerender(<WorkbenchPanel {...props} port={port} tabs={[{ ...other, active: false }, reused]} manual revealChanges={1} />);
await waitFor(() => expect(filesShown()).toBe(false));
expect(document.querySelector('[data-target="browser:t1"]')?.closest('[role="tab"]')?.getAttribute("aria-selected")).toBe("true");
});
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { MockPort } from "../port/mock";
import type { BrowserTab } from "../port/port";
import { host } from "../port/host";
import { openPage } from "./browser_open";
const page = { id: "b1", target: "t1", url: "https://example.com", title: "Existing page", active: true };
const props = { scheme: "dark" as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
const filesShown = () => document.querySelector(".workbench-body")!.hasAttribute("data-files");
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
it("gives an explicit files request priority over the existing agent page when opening the dock", () => {
const port = new MockPort();
const browser = vi.spyOn(port, "browserOpen");
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[page]} manual={false} />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual revealChanges={1} />);
expect(filesShown()).toBe(true);
expect(browser).not.toHaveBeenCalled();
});
it("keeps a newer files request visible when the previously followed link finishes opening", async () => {
const port = new MockPort();
let resolve!: (tab: BrowserTab) => void;
vi.spyOn(port, "browserOpen").mockReturnValue(new Promise((done) => { resolve = done; }));
const pending = openPage(port, page.url);
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
expect(filesShown()).toBe(true);
await act(async () => { resolve(page); await pending; });
expect(filesShown()).toBe(true);
});
it("keeps Files visible when an earlier explicit browser request finishes", async () => {
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(true);
const port = new MockPort();
let resolve!: (tab: BrowserTab) => void;
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual={false} />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual browserAsk={1} />);
expect(port.browserOpen).toHaveBeenCalledTimes(1);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual browserAsk={1} revealChanges={1} />);
await act(async () => { resolve(page); await pending; });
expect(filesShown()).toBe(true);
});
it.each([false, true])("lets an explicit title-bar Browser request replace Files (native=%s)", async (native) => {
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(native);
const port = new MockPort();
let resolve!: (tab: BrowserTab) => void;
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
if (native) void openPage(port, page.url);
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
await act(async () => { resolve(page); await pending; });
rerender(<WorkbenchPanel {...props} port={port} tabs={native ? [page] : []} manual revealChanges={1} />);
expect(filesShown()).toBe(true);
rerender(<WorkbenchPanel {...props} port={port} tabs={native ? [page] : []} manual revealChanges={1} browserAsk={1} />);
expect(filesShown()).toBe(false);
});
it("does not replace a file picked while the title-bar browser request is pending", async () => {
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(true);
const port = new MockPort();
let resolve!: (tab: BrowserTab) => void;
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual shown />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual shown browserAsk={1} />);
fireEvent.click(await screen.findByRole("button", { name: "README.md" }));
await act(async () => { resolve(page); await pending; });
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual shown browserAsk={1} />);
await act(async () => {});
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe("true");
});
@@ -0,0 +1,152 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { MockPort } from "../port/mock";
import { host } from "../port/host";
import { HttpError, type BrowserTab } from "../port/port";
import { observePageTabs, openPage, unseenPageOpenings } from "./browser_open";
const agent: BrowserTab = {id:'a', target:'ta', url:'https://agent.example', title:'Agent', active:true};
const page: BrowserTab = {id:'b', target:'tb', url:'file:///w/index.html', title:'Failed preview', active:false};
const props = {manual:false, shown:true, scheme:'dark' as const, changes:[], onSurfaces:vi.fn(), onCloseManual:vi.fn(), onExternal:vi.fn()};
const filesShown = () => document.querySelector('.workbench-body')!.hasAttribute('data-files');
afterEach(() => {cleanup(); vi.restoreAllMocks();});
it.each(['event-first', 'reply-first'])('preserves newer Files when preview workspace lookup delays browser request: %s', async (order) => {
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
const port = new MockPort();
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
vi.spyOn(port, 'workspaceFile').mockResolvedValue({path:'index.html',content:'hello',revision:'r1'});
let root!: (v: Awaited<ReturnType<MockPort['workspaces']>>) => void;
const workspaces = vi.spyOn(port, 'workspaces').mockReturnValue(new Promise(done => {root=done;}));
let fail!: (e: unknown) => void;
const open = vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((_,reject)=>{fail=reject;}));
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
await waitFor(()=>expect(workspaces).toHaveBeenCalled());
expect(open).not.toHaveBeenCalled();
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
expect(filesShown()).toBe(true);
await act(async()=>root({current:'/w',canSwitch:true,canIsolate:true,recents:[]}));
expect(open).toHaveBeenCalledOnce();
if(order==='event-first') rerender(<WorkbenchPanel {...props} port={port} tabs={[agent,page]} />);
await act(async()=>fail(new HttpError(400,'failed',{code:'browser.open_failed',params:{tab:page.id}})));
if(order==='reply-first') rerender(<WorkbenchPanel {...props} port={port} tabs={[agent,page]} />);
await act(async()=>{});
expect(filesShown()).toBe(true);
expect(screen.getByRole('tab',{name:'Failed preview'}).getAttribute('aria-selected')).toBe('false');
});
it('retires an opening whose tab was observed then closed before the response', async()=>{
const port=new MockPort(); let done!: (v:BrowserTab)=>void;
vi.spyOn(port,'browserOpen').mockReturnValue(new Promise(resolve=>{done=resolve;}));
const {rerender}=render(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent]} />);
const pending=openPage(port,page.url);
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent,page]} />);
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent]} />);
await act(async()=>{done(page);await pending;});
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it('does not start a preview after its file read finishes behind a newer Files selection', async () => {
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
const port = new MockPort();
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
let read!: (file: Awaited<ReturnType<MockPort['workspaceFile']>>) => void;
const reading = vi.spyOn(port, 'workspaceFile').mockReturnValue(new Promise(done => {read=done;}));
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
await waitFor(()=>expect(reading).toHaveBeenCalled());
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
await act(async()=>read({path:'index.html',content:'hello',revision:'r1'}));
expect(open).not.toHaveBeenCalled();
expect(filesShown()).toBe(true);
fireEvent.click(document.querySelector('[data-action="workbench.tab"][data-target="file:index.html"]')!);
await waitFor(()=>expect(open).toHaveBeenCalledOnce());
});
it('follows a genuine agent tab while an older preview is still preparing its workspace', async () => {
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
const port = new MockPort();
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
vi.spyOn(port, 'workspaceFile').mockResolvedValue({path:'index.html',content:'hello',revision:'r1'});
let root!: (value: Awaited<ReturnType<MockPort['workspaces']>>) => void;
const lookup = vi.spyOn(port, 'workspaces').mockReturnValue(new Promise(done => {root=done;}));
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
await waitFor(()=>expect(lookup).toHaveBeenCalled());
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
const fresh = {...agent,id:'fresh',target:'tf',title:'Fresh',active:false};
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent,fresh]} />);
await waitFor(()=>expect(screen.getByRole('tab',{name:'Fresh'}).getAttribute('aria-selected')).toBe('true'));
expect(open).not.toHaveBeenCalled();
await act(async()=>root({current:'/w',canSwitch:true,canIsolate:true,recents:[]}));
});
it.each(['fulfilled', 'rejected'])('remembers raw observations through a claim and close until %s settlement reconciles', async (outcome) => {
const port = new MockPort();
let done!: (tab: BrowserTab) => void, fail!: (error: unknown) => void;
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise((resolve, reject) => {done=resolve; fail=reject;}));
const pending = openPage(port, page.url).catch(() => {});
observePageTabs(port, [page], false);
observePageTabs(port, [], false);
await act(async () => {
if (outcome === 'fulfilled') done(page);
else fail(new HttpError(400, 'failed', {code:'browser.open_failed',params:{tab:page.id}}));
await pending;
});
expect(unseenPageOpenings(port)).toHaveLength(1);
observePageTabs(port, [], true);
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it('does not reuse historical observations for a new opening with the same public ID', async () => {
const port = new MockPort();
let done!: (tab: BrowserTab) => void;
vi.spyOn(port, 'browserOpen').mockImplementation(() => new Promise(resolve => {done=resolve;}));
const first = openPage(port, page.url);
observePageTabs(port, [page]);
observePageTabs(port, []);
await act(async () => {done(page); await first;});
expect(unseenPageOpenings(port)).toHaveLength(0);
const next = openPage(port, page.url);
await act(async () => {done(page); await next;});
expect(unseenPageOpenings(port)).toHaveLength(1);
observePageTabs(port, [page]);
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it.each(['resolve', 'reject'])('preserves new mounted panel Files selection after an old open %s', async(outcome)=>{
const port=new MockPort(); let done!: (v:BrowserTab)=>void; let fail!: (e:unknown)=>void;
vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((resolve,reject)=>{done=resolve;fail=reject;}));
const pending=openPage(port,page.url).catch(()=>{});
const initial=render(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[agent]} />);
initial.unmount();
const {rerender}=render(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[agent]} />);
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
rerender(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[agent,page]} />);
await act(async()=>{if(outcome==='resolve') done(page); else fail(new HttpError(400,'failed',{code:'browser.open_failed',params:{tab:page.id}})); await pending;});
expect(filesShown()).toBe(true);
expect(unseenPageOpenings(port)).toHaveLength(0);
rerender(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[{...agent,active:false},{...page,active:true,url:'https://legitimate.example'}]} />);
await act(async()=>{});
expect(filesShown()).toBe(false);
expect(screen.getByRole('tab',{name:'Failed preview'}).getAttribute('aria-selected')).toBe('true');
});
it.each(['resolve', 'reject'])('preserves latest Files choice across concurrent mixed opens that %s independently', async(outcome)=>{
const port=new MockPort(); const done: ((v:BrowserTab)=>void)[]=[]; const fail: ((e:unknown)=>void)[]=[];
vi.spyOn(port,'browserOpen').mockImplementation(()=>new Promise((resolve,reject)=>{done.push(resolve);fail.push(reject);}));
const first=openPage(port,page.url).catch(()=>{}); const second=openPage(port,'https://second.example').catch(()=>{});
const other={...page,id:'c',target:'tc',title:'Second'}; const genuine={...page,id:'d',target:'td',title:'Genuine'};
const {rerender}=render(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent]} />);
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent,page]} />);
await act(async()=>{done[0](page);await first;});
expect(filesShown()).toBe(true);
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent,page,other,genuine]} />);
await act(async()=>{if(outcome==='resolve')done[1](other);else fail[1](new HttpError(400,'failed',{code:'browser.open_failed',params:{tab:other.id}}));await second;});
expect(filesShown()).toBe(false);
expect(screen.getByRole('tab',{name:'Genuine'}).getAttribute('aria-selected')).toBe('true');
expect(unseenPageOpenings(port)).toHaveLength(0);
});
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { MockPort } from "../port/mock";
import { SsePort } from "../port/sse";
import { openPage, unseenPageOpenings } from "./browser_open";
const page = { id: "b1", target: "t1", url: "https://example.com", title: "Failed page", active: false };
const other = { ...page, id: "b2", target: "t2", title: "Agent page", active: true };
const props = { scheme: "dark" as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
const filesShown = () => document.querySelector(".workbench-body")!.hasAttribute("data-files");
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
function failedOpen() {
let respond!: (response: Response) => void;
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>((done) => { respond = done; })));
const port = new MockPort();
const wire = new SsePort("", "failed-open");
vi.spyOn(port, "browserOpen").mockImplementation((url) => wire.browserOpen(url, true));
const pending = openPage(port, page.url).catch(() => {});
const reject = async (reason = { code: "browser.open_failed", params: { tab: page.id, url: page.url, error: "navigation failed" } }) => {
await act(async () => { respond(new Response(JSON.stringify(reason), { status: 400 })); await pending; });
};
return { port, reject };
}
it.each([
["before", "navigation"], ["after", "navigation"],
["before", "activation"], ["after", "activation"],
])("keeps Files when failed tab arrives %s rejection, then follows later %s", async (order, reuse) => {
const { port, reject } = failedOpen();
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[other]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[other]} manual revealChanges={1} />);
const first = { ...page, active: reuse === "navigation" };
const agent = { ...other, active: !first.active };
if (order === "after") await reject();
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent, first]} manual revealChanges={1} />);
if (order === "before") await reject();
await act(async () => {});
expect(filesShown()).toBe(true);
expect(unseenPageOpenings(port)).toHaveLength(0);
const reused = { ...first, active: true, url: reuse === "navigation" ? `${page.url}/next` : page.url };
rerender(<WorkbenchPanel {...props} port={port} tabs={[{ ...other, active: false }, reused]} manual revealChanges={1} />);
await waitFor(() => expect(filesShown()).toBe(false));
expect(document.querySelector('[data-target="browser:t1"]')?.closest('[role="tab"]')?.getAttribute("aria-selected")).toBe("true");
});
it.each(["browser.open_failed", "unrelated.failure"])("does not claim an agent tab from %s without a valid tab identity", async (code) => {
const { port, reject } = failedOpen();
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
await reject({ code, params: { tab: code === "browser.open_failed" ? "" : page.id, url: page.url, error: "failed" } });
expect(unseenPageOpenings(port)).toHaveLength(0);
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual revealChanges={1} />);
await waitFor(() => expect(filesShown()).toBe(false));
});
it.each(["before", "after"])("follows a genuine agent tab beside the failed user tab (event %s rejection)", async (order) => {
const { port, reject } = failedOpen();
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
if (order === "after") await reject();
rerender(<WorkbenchPanel {...props} port={port} tabs={[other, page]} manual revealChanges={1} />);
if (order === "before") await reject();
await waitFor(() => expect(filesShown()).toBe(false));
expect(document.querySelector('[data-target="browser:t2"]')?.closest('[role="tab"]')?.getAttribute("aria-selected")).toBe("true");
expect(unseenPageOpenings(port)).toHaveLength(0);
});