feat(studio): one way into the workbench

The title-bar Browser button and the Workbench tab opened the same column but
the button also asked for a blank browser page. The tab is now the only entry.
This commit is contained in:
esengine committed 2026-10-14 03:56:09 -07:00
1 parent a22ee52eaa
commit aeb46aeb4e
22 files changed
+63 -274

No files matched your search

+3 -3
View File
@@ -33,7 +33,7 @@ for (const scheme of ["light", "dark"]) {
for (const round of ["first open", "after a reload"]) {
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="browser.open"]').first().click();
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
await page.waitForTimeout(700);
const open = await geometry(page);
docked = open;
@@ -51,7 +51,7 @@ for (const scheme of ["light", "dark"]) {
}
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
}
await page.locator('[data-action="browser.open"]').first().click();
await page.locator('[data-action="pane.view"][data-value="flow"]').first().click();
await page.waitForTimeout(700);
const shut = await geometry(page);
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
@@ -67,7 +67,7 @@ for (const scheme of ["light", "dark"]) {
await page.setViewportSize({ width, height: 800 });
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="browser.open"]').first().click();
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
await page.waitForTimeout(400);
const g = await page.evaluate(() => {
const tabs = document.querySelector(".pane > .tabs");
+1 -1
View File
@@ -50,7 +50,7 @@ const probe = (page) =>
};
});
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-action"];
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action"];
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
async function reachable(page) {
-5
View File
@@ -18,11 +18,6 @@ for (const scheme of ["light", "dark"]) {
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const tag = `${scheme}/${label}`;
const tabs = () => page.evaluate(() => document.querySelectorAll(".workbench-tab").length);
await page.locator('[data-action="browser.open"]').first().click();
await page.waitForTimeout(700);
const direct = await page.evaluate(() => ({ launcher: !!document.querySelector(".workbench-launcher"), tabs: document.querySelectorAll(".workbench-tab").length }));
check(`${tag}: the title-bar Browser button goes straight to a browser`, direct.tabs === 1 && !direct.launcher, JSON.stringify(direct));
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="pane.view"][data-value="browser"]').click();
@@ -117,8 +117,6 @@ export const EN_WINDOW: Record<string, string> = {
"Reasonix 内置 Browser": "Reasonix built-in Browser",
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
"正在打开网页…": "Opening page…",
"收起工作台": "Collapse the workbench",
"打开内置 Browser": "Open the built-in Browser",
"在上方地址栏输入网址,即可在当前任务旁浏览资料。": "Enter an address above to browse beside the current task.",
"浏览内容不会替换或中断当前会话。部分网站禁止嵌入时,可使用右上角的外部打开。": "Browsing will not replace or interrupt the current task. If a site blocks embedding, use Open externally in the toolbar.",
"点击步骤可原位查看详情、结果或文件差异": "Select a step to inspect its details, result, or file diff in place",
@@ -25,10 +25,6 @@ const DECORATIVE: [string, string, string[]][] = [
["studio", ".chipmirror .skillchip", ["--brand", "--brand-wash"]],
["studio", ".studio-wallet > span", ["--brand"]],
["studio", ':root[data-theme="light"] .studio-wallet > span', ["--brand"]],
["studio", ".chrome .browser-action", ["--brand"]],
["studio", '.chrome .browser-action[aria-pressed="true"]', ["--brand-wash"]],
["studio", ':root[data-theme="light"] .chrome .browser-action', ["--brand"]],
["studio", ':root[data-theme="light"] .chrome .browser-action[aria-pressed="true"]', ["--brand-wash"]],
["studio", ".flow .opt[data-on]", ["--brand-wash"]],
["studio", ':root[data-theme="light"] .flow .opt[data-on]', ["--brand-wash"]],
["feedback", '.fbk-note[data-tone="info"] > .studio-icon', ["--brand"]],
@@ -497,8 +497,6 @@ body { background: var(--page) !important; }
.chrome .thbtn:hover,
.chrome .top-action:hover,
.chrome .top-action[aria-pressed="true"] { color: var(--text); background: var(--float); border-color: var(--border); }
.chrome .browser-action { color: var(--brand); }
.chrome .browser-action[aria-pressed="true"] { color: #c8d3f0; background: var(--brand-wash); }
.chrome-context-card {
position: absolute; z-index: 80; top: calc(100% + 8px); right: 0; width: 326px;
display: grid; gap: 10px; padding: 13px; border: 1px solid var(--border); border-radius: 10px;
@@ -2568,8 +2566,6 @@ body { font-size: 14px; line-height: 1.75; }
:root[data-theme="light"] .studio-new-task > span,
:root[data-theme="light"] .studio-search > span,
:root[data-theme="light"] .studio-wallet > span { color: var(--brand); }
:root[data-theme="light"] .chrome .browser-action { color: var(--brand); }
:root[data-theme="light"] .chrome .browser-action[aria-pressed="true"] { color: #3f568b; background: var(--brand-wash); }
:root[data-theme="light"] .studio-browser,
:root[data-theme="light"] .studio-browser-frame { background: var(--page); color: var(--text); }
:root[data-theme="light"] .studio-browser-toolbar,
@@ -2948,8 +2944,6 @@ body { font-size: 14px; line-height: 1.75; }
.studio-settings { color: var(--ghost); }
.chrome .badge,
.chrome .top-action[aria-pressed="true"] { background: var(--overlay); color: var(--muted); border-color: var(--border); }
.chrome .browser-action { color: var(--accent); }
.chrome .browser-action[aria-pressed="true"] { color: var(--accent); background: var(--accent-wash); }
.studio-brand-sub { color: var(--faint); }
.studio-settings:hover,
@@ -4203,7 +4197,6 @@ body { font-size: 14px; line-height: 1.75; }
--pal-accent: var(--default-accent);
}
:root[data-pack] .chrome .browser-action[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent-wash); }
:root[data-pack] .chrome-context-card section > button { color: var(--link); }
:root[data-pack] .studio-brand-accent { color: var(--brand); }
:root[data-pack] .navbtn[aria-current],
-7
View File
@@ -119,7 +119,6 @@ export function App({ hub }: { hub: HubPort }) {
const [feedback, setFeedback] = useState<FeedbackTab | null>(null);
const [feedbackUnread, setFeedbackUnread] = useState(0);
const [browser, setBrowser] = useState(false);
const [browserAsk, setBrowserAsk] = useState(0);
const [setup, setSetup] = useState<ProviderSetup | null | undefined>(undefined);
// undefined until asked; false means the opening sequence is still owed.
const [welcomed, setWelcomed] = useState<boolean | undefined>(undefined);
@@ -564,11 +563,6 @@ export function App({ hub }: { hub: HubPort }) {
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
steer={report.steer}
onSettings={showPrefs}
onBrowser={() => {
if (!browser) setBrowserAsk((n) => n + 1);
setBrowser((value) => !value);
}}
browser={browser}
account={account}
theme={scheme}
onTheme={() => setTheme(scheme === "dark" ? "light" : "dark")}
@@ -702,7 +696,6 @@ export function App({ hub }: { hub: HubPort }) {
dockMax={dockLimit}
onDockW={onDockW}
manualBrowser={rt.id === active && browser}
browserAsk={browserAsk}
onManualBrowser={setBrowser}
/>
) : null;
+1 -6
View File
@@ -27,8 +27,6 @@ interface Props {
title?: string;
steer: number;
onSettings: (section?: string) => void;
onBrowser: () => void;
browser: boolean;
account: AccountState | null;
theme: string;
onTheme: () => void;
@@ -38,7 +36,7 @@ interface Props {
onError?: (e: unknown) => void;
}
export function Chrome({ port, status, title, steer, onSettings, onBrowser, browser, account, host, theme, onTheme, onFind, hub, onError }: Props) {
export function Chrome({ port, status, title, steer, onSettings, account, host, theme, onTheme, onFind, hub, onError }: Props) {
const root = status?.workspaceRoot || status?.cwd || "";
const project = root ? base(root) : "—";
// The folder mark opens the project itself. Only a shell that can enter a
@@ -149,9 +147,6 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow
<button className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m8 2.5 5 2.7v5.6l-5 2.7-5-2.7V5.2Z" /><path d="m3 5.2 5 2.7 5-2.7M8 7.9v5.6" /></svg><span>{t("沙盒")}</span>
</button>
<button className="thbtn browser-action" data-action="browser.open" onClick={onBrowser} aria-pressed={browser} aria-label="Browser" title={browser ? t("收起工作台") : t("打开内置 Browser")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.4" /><path d="M2.8 8h10.4M8 2.6c1.5 1.5 2.3 3.3 2.3 5.4S9.5 11.9 8 13.4C6.5 11.9 5.7 10.1 5.7 8S6.5 4.1 8 2.6Z" /></svg>
</button>
</div>
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
<WindowControls />
+1 -2
View File
@@ -81,7 +81,7 @@ const totalsOf = (st: SessionStatus) => ({
incompleteReason: st.sessionCostQuote?.incompleteReason,
});
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, filesPulse = 0, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, 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, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
const [s, dispatch] = useReducer(reduce, initialState);
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
@@ -521,7 +521,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
tabs={pages}
wideMode={docked ? { wide: browserSize.size === "wide", onToggle: browserSize.toggle } : undefined}
manual={manualBrowser}
browserAsk={browserAsk}
shown={visible && workbench}
onSurfaces={setSurfaces}
scheme={theme === "light" ? "light" : "dark"}
@@ -260,18 +260,6 @@ describe("WorkbenchPanel", () => {
} finally { views.mockRestore(); hide.mockRestore(); vi.unstubAllGlobals(); }
});
it("dismisses the launcher when the title-bar Browser is reopened on an iframe host", async () => {
const user = userEvent.setup();
const props = { port: new MockPort(), tabs: [], manual: true, shown: true, scheme: "light" as const, changes: [], onCloseManual: vi.fn(), onSurfaces: vi.fn(), onExternal: vi.fn() };
const { rerender } = render(<WorkbenchPanel {...props} browserAsk={0} />);
await user.click(screen.getByRole("button", { name: "新建标签" }));
rerender(<WorkbenchPanel {...props} manual={false} browserAsk={1} />);
rerender(<WorkbenchPanel {...props} browserAsk={2} />);
expect(screen.queryByRole("group", { name: "开始" })).toBeNull();
expect(screen.getByRole("tab", { name: "浏览器" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("region", { name: "内置 Browser" })).toBeTruthy();
});
it.each(["escape", "plus"])("restores the explorer when new-tab choice is cancelled with %s", async (cancel) => {
const user = userEvent.setup();
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
@@ -283,7 +271,7 @@ describe("WorkbenchPanel", () => {
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
});
it.each([[false, false], [true, false], [false, true]])("respects newer Files choices (reopen: %s, title bar: %s)", async (reopen, titleBar) => {
it.each([false, true])("respects newer Files choices (reopen: %s)", async (reopen) => {
const user = userEvent.setup();
const port = new MockPort();
const views = vi.spyOn(hostModule.host(), "drawsBrowserViews").mockReturnValue(true);
@@ -291,12 +279,9 @@ describe("WorkbenchPanel", () => {
vi.spyOn(port, "browserOpen").mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
try {
const props = { port, tabs: [], manual: true, shown: true, scheme: "light" as const, changes: [], onCloseManual: vi.fn(), onSurfaces: vi.fn(), onExternal: vi.fn() };
const { container, rerender } = render(<WorkbenchPanel {...props} browserAsk={0} />);
if (titleBar) rerender(<WorkbenchPanel {...props} browserAsk={1} />);
else {
await user.click(screen.getByRole("button", { name: "新建标签" }));
await user.click(screen.getByRole("button", { name: "浏览器" }));
}
const { container } = render(<WorkbenchPanel {...props} />);
await user.click(screen.getByRole("button", { name: "新建标签" }));
await user.click(screen.getByRole("button", { name: "浏览器" }));
await user.click(container.querySelector('[data-action="workbench.launch-files"]')!);
if (reopen) {
await user.click(screen.getByRole("button", { name: "新建标签" }));
@@ -105,7 +105,6 @@ export function WorkbenchPanel({
port,
tabs: allTabs,
manual,
browserAsk = 0,
shown,
scheme,
changes,
@@ -128,7 +127,6 @@ export function WorkbenchPanel({
tabs: BrowserTab[];
manual: boolean;
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
browserAsk?: number;
shown: boolean;
scheme: "light" | "dark";
changes: WorkspaceChange[];
@@ -277,7 +275,7 @@ export function WorkbenchPanel({
const agentNow = useRef(agentTarget);
agentNow.current = agentTarget;
const filesAsk = useMemo(() => ({ revealChanges, fileRequest }), [revealChanges, fileRequest]);
useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
useColumnOpening({ manual, filesAsk, port, agentNow, dropBlankStart, follow, current, setFailed });
// 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.
@@ -1,76 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { App } from "./App";
import { MockHub } from "../port/mock_hub";
import { boot, STORAGE } from "../i18n";
import { refresh } from "./viewport";
// jsdom lays nothing out, so the width the fold reads is whatever is said here.
function room(width: number) {
vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(width);
vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900);
refresh();
}
beforeEach(async () => {
await import("./Settings");
sessionStorage.clear();
localStorage.clear();
localStorage.setItem(STORAGE, "zh");
boot();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
room(1440);
});
afterEach(() => {
cleanup();
sessionStorage.clear();
localStorage.clear();
delete document.documentElement.dataset.fold;
vi.restoreAllMocks();
});
async function open() {
const hub = new MockHub();
const build = hub.portFor.bind(hub);
const prepared = new WeakSet();
hub.portFor = (rt) => {
const port = build(rt);
if (!prepared.has(port)) {
prepared.add(port);
port.providerSetup = async () => null;
port.welcomeSeen = async () => true;
}
return port;
};
const view = render(<App hub={hub} />);
await screen.findByRole("combobox", { name: "任务输入" });
return { hub, view };
}
const browserButton = () => screen.getByRole("button", { name: "Browser" });
it("the title-bar Browser button opens the column on a browser", async () => {
await open();
await userEvent.click(browserButton());
expect(await screen.findByRole("tab", { name: "浏览器" })).toBeTruthy();
expect(screen.queryByRole("group", { name: "开始" })).toBeNull();
await userEvent.click(browserButton());
expect(browserButton().getAttribute("title")).toBe("打开内置 Browser");
});
it("the Workbench tab opens the column on the launcher, with no browser made for the person", async () => {
await open();
await userEvent.click(screen.getByRole("tab", { name: /工作台/ }));
expect(await screen.findByRole("group", { name: "开始" })).toBeTruthy();
expect(screen.queryByRole("tab", { name: "浏览器" })).toBeNull();
});
it("says the button collapses the workbench while the column is open", async () => {
await open();
await userEvent.click(browserButton());
expect(browserButton().getAttribute("title")).toBe("收起工作台");
});
@@ -1,7 +1,7 @@
import { expect, it, vi } from "vitest";
import { HttpError } from "../port/http_error";
import type { AgentPort, BrowserTab } from "../port/port";
import { failedPageID, observePageTabs, openPage, pageForEmptyColumn, pageInFlight, preparePage, readPageTabs, recheckPageTabs, pageReadAfterSettlement, unseenPageOpenings } from "./browser_open";
import { failedPageID, observePageTabs, openPage, pageInFlight, preparePage, readPageTabs, recheckPageTabs, pageReadAfterSettlement, unseenPageOpenings } from "./browser_open";
it.each(["browser.open_failed", "browser.engine_missing", "browser.engine_failed", "browser.profile_busy"])("reads the public tab identity from %s", (code) => {
expect(failedPageID(new HttpError(400, "failed", { code, params: { tab: "b1" } }))).toBe("b1");
@@ -31,50 +31,6 @@ it("reserves preparation before it runs and releases a failure that created no t
expect(unseenPageOpenings(port)).toHaveLength(0);
});
it("does not make an explicit browser wait for an unstarted preview preparation", async () => {
const tab: BrowserTab = {id:"blank",target:"blank-target",url:"about:blank",title:"Blank",active:false};
const port = { browserOpen: vi.fn(async () => tab) } as unknown as AgentPort;
let ready!: (tab: BrowserTab) => void;
const prepared = preparePage(port, () => new Promise((resolve) => {ready=resolve;}));
const browser = pageForEmptyColumn(port);
expect(port.browserOpen).toHaveBeenCalledWith("about:blank", true);
expect(await browser).toBe(tab);
ready({...tab,id:"preview",target:"preview-target"});
await prepared;
expect(port.browserOpen).toHaveBeenCalledTimes(1);
});
it("still shares an actual browser request when a later preview is only preparing", async () => {
const tab: BrowserTab = {id:"native",target:"native-target",url:"about:blank",title:"Native",active:false};
let done!: (tab: BrowserTab) => void, ready!: (tab: BrowserTab) => void;
const port = { browserOpen: vi.fn(() => new Promise<BrowserTab>((resolve) => {done=resolve;})) } as unknown as AgentPort;
const native = openPage(port, "about:blank");
const prepared = preparePage(port, () => new Promise((resolve) => {ready=resolve;}));
expect(pageInFlight(port)).not.toBeNull();
const shared = pageForEmptyColumn(port);
expect(port.browserOpen).toHaveBeenCalledTimes(1);
done(tab);
expect(await shared).toBe(tab);
await native;
ready({...tab,id:"existing",target:"existing-target"});
await prepared;
});
it("does not redirect a newer browser request to an older preparation that starts late", async () => {
const native: BrowserTab = {id:"native",target:"native-target",url:"about:blank",title:"Native",active:false};
const preview = {...native,id:"preview",target:"preview-target",url:"file:///w/index.html"};
let ready!: (url: string) => void, done!: (tab: BrowserTab) => void;
const port = { browserOpen: vi.fn((url: string) => url === "about:blank"
? new Promise<BrowserTab>((resolve) => {done=resolve;}) : Promise.resolve(preview)) } as unknown as AgentPort;
const prepared = preparePage(port, () => new Promise((resolve) => {ready=resolve;}));
const requested = pageForEmptyColumn(port);
await Promise.resolve();
ready(preview.url);
await prepared;
done(native);
expect(await requested).toBe(native);
});
it.each([false, true])("keeps settled navigation ownership until its URL is observed, or its early tab closes: %s", async (closed) => {
const tab: BrowserTab = {id:"page",target:"target",url:"https://final.example/",title:"Final",active:true};
let done!: (tab: BrowserTab) => void;
@@ -146,12 +146,6 @@ export function pageInFlight(port: AgentPort): Promise<BrowserTab> | null {
return current?.pending && current.started ? newestPage(port, current.page) : null;
}
/** A page for a column that has none: the one already on its way, else a blank. */
export function pageForEmptyColumn(port: AgentPort): Promise<BrowserTab> {
const current = opening.get(port);
return newestPage(port, current?.pending && current.started ? current.page : openPage(port, "about:blank"));
}
async function newestPage(port: AgentPort, page: Promise<BrowserTab>): Promise<BrowserTab> {
try {
const tab = await page;
@@ -67,7 +67,7 @@ it("gathers the controls under one button below the bar fold, keeps them in the
expect(document.documentElement.dataset.fold).toContain("bar");
const button = more()!;
expect(button.getAttribute("aria-expanded")).toBe("false");
for (const sel of [".find-action", ".theme-toggle", ".top-action", ".browser-action"]) {
for (const sel of [".find-action", ".theme-toggle", ".top-action"]) {
expect(document.querySelector(`.chrome .r-fold ${sel}`), sel).not.toBeNull();
}
await userEvent.click(button);
@@ -29,8 +29,6 @@ async function draw(shell: Record<string, unknown>, host?: string) {
title="会话"
steer={0}
onSettings={vi.fn()}
onBrowser={vi.fn()}
browser={false}
account={null}
theme="dark"
onTheme={vi.fn()}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { App } from "./App";
import { MockHub } from "../port/mock_hub";
afterEach(cleanup);
function readyHub() {
const hub = new MockHub();
const build = hub.portFor.bind(hub);
const opened = vi.fn();
hub.portFor = (rt) => {
const port = build(rt);
port.providerSetup = async () => null;
port.welcomeSeen = async () => true;
const open = port.browserOpen?.bind(port);
port.browserOpen = ((...args: Parameters<typeof port.browserOpen>) => { opened(...args); return open!(...args); }) as typeof port.browserOpen;
return port;
};
return { hub, opened };
}
describe("the workbench has one way in", () => {
it("has no separate browser button in the title bar", async () => {
render(<App hub={readyHub().hub} />);
await screen.findByRole("tab", { name: /工作台/ });
expect(document.querySelector(".chrome .browser-action")).toBeNull();
expect(document.querySelector('[data-action="browser.open"]')).toBeNull();
});
it("opens on its start page when nothing is running, and does not open a blank browser page", async () => {
const user = userEvent.setup();
const { hub, opened } = readyHub();
render(<App hub={hub} />);
await user.click(await screen.findByRole("tab", { name: /工作台/ }));
await waitFor(() => expect(document.querySelector(".workbench-launcher")).not.toBeNull());
expect(opened).not.toHaveBeenCalled();
});
});
@@ -47,8 +47,7 @@ async function mount(running = false, pathless = false) {
const [report, setReport] = useState<PaneReport | null>(null);
const onReport = useCallback((_: string, next: PaneReport) => setReport(next), []);
return <>
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} onSettings={() => {}} onBrowser={() => {}}
browser={false} account={null} theme="light" onTheme={() => {}} onFind={() => {}} />
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} onSettings={() => {}} account={null} theme="light" onTheme={() => {}} onFind={() => {}} />
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath }}
title="active" active visible sideHost={null} side={false} onFocus={() => {}} onReport={onReport}
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
@@ -61,7 +61,6 @@ export interface PaneProps {
dockMax: number;
onDockW: (w: number) => void;
manualBrowser?: boolean;
browserAsk?: number;
onManualBrowser?: (on: boolean) => void;
// The window's failed-request notice, handed only to the pane in front.
alert?: ReactNode;
@@ -1,31 +1,23 @@
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { reason } from "../i18n/kernel";
import { host } from "../port/host";
import type { AgentPort } from "../port/port";
import { pageForEmptyColumn, pageInFlight } from "./browser_open";
import { pageInFlight } from "./browser_open";
interface Opening {
manual: boolean;
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
browserAsk: number;
filesAsk: unknown;
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[]>>;
}
/** What the workbench column does when it is opened. It follows the agent's page,
* and a page a followed link already has on its way; with neither it stays on
* 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, filesAsk, port, agentNow, dropBlankStart, pick, follow, manualSelection, current, setFailed, setBrowsers }: Opening) {
* its launcher until a browser is asked for from there. */
export function useColumnOpening({ manual, filesAsk, port, agentNow, dropBlankStart, follow, current, setFailed }: Opening) {
const filesRequested = useRef(filesAsk);
useEffect(() => {
const files = filesRequested.current !== filesAsk;
@@ -43,28 +35,4 @@ export function useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow,
.catch((e) => { if (alive && valid()) setFailed(reason(e)); });
return () => { alive = false; };
}, [manual, filesAsk, agentNow, dropBlankStart, follow, port, setFailed, current]);
const asked = useRef(browserAsk);
useEffect(() => {
if (asked.current === browserAsk) return;
asked.current = browserAsk;
if (!manual) return;
if (agentNow.current) {
dropBlankStart();
pick(`browser:${agentNow.current}`);
return;
}
// A shell that draws views opens a view here too: a framed page is refused
// 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 (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, filesAsk, manual, agentNow, dropBlankStart, pick, port, setFailed, setBrowsers, manualSelection, current]);
}
@@ -1,11 +1,10 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, render } 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 };
@@ -33,48 +32,3 @@ it("keeps a newer files request visible when the previously followed link finish
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");
});
+2
View File
@@ -0,0 +1,2 @@
- 去掉顶栏的「浏览器」按钮:「工作台」标签是唯一入口,没有运行中的网页时显示「文件 / 改动 / 浏览器」启动页,不再直接开一个空白页。关联 #13067。
- The title-bar Browser button is gone: the Workbench tab is the one way in, and with nothing running it shows the Files / Changes / Browser start page instead of opening a blank page. Refs #13067.