Skip unrelated required checks without losing their names (#2542)

* fix(ci): skip unrelated required checks cleanly

Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable.

* fix(ci): gate changes to install patching script

* fix(ci): route cross-package test contracts narrowly

* fix(ci): keep routed contracts merge-blocking

* fix(server): exclude nested test utilities from builds

* fix(ci): cover app-owned native and browser domains

* refactor(ci): align tests with runtime domains

Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check.

* fix(ci): keep routing and desktop suites isolated

The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs.

* fix(ci): isolate desktop e2e runtime

* perf(test): isolate Playwright workers

* perf(test): parallelize Playwright within specs

* fix(test): keep Playwright file scheduling

* fix(test): share dynamic Metro fixture

* fix(test): preserve own-host restart fixtures

* fix(ci): preserve routed contracts after rebase

* Stabilize Playwright wheel-scroll assertions
This commit is contained in:
Mohamed Boudra
2026-08-02 00:56:05 +08:00
committed by GitHub
parent 429155b536
commit 5a30affd7f
256 changed files with 2693 additions and 2674 deletions
@@ -0,0 +1,606 @@
import { beforeEach, describe, expect, test } from "vitest";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/desktop/browser/store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
class FakeStateStorage implements StateStorage {
private readonly values = new Map<string, string>();
public getItem = (key: string): string | null => this.values.get(key) ?? null;
public setItem = (key: string, value: string): void => {
this.values.set(key, value);
};
public removeItem = (key: string): void => {
this.values.delete(key);
};
public clear(): void {
this.values.clear();
}
}
class FakeDaemonClient {
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
public on(
type: "browser.automation.execute.request",
handler: (request: BrowserAutomationExecuteRequest) => void,
): () => void {
expect(type).toBe("browser.automation.execute.request");
this.handler = handler;
return () => {
if (this.handler === handler) {
this.handler = null;
}
};
}
public sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
this.sentResponses.push(response);
}
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
this.handler?.(nextRequest);
}
public payloadAt(index: number): BrowserAutomationResponsePayload {
const response = this.sentResponses[index];
if (!response) {
throw new Error(`Missing browser automation response at index ${index}`);
}
return response.payload;
}
}
class FakeBrowserBridge {
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
public readonly unregisteredWorkspaceBrowsers: string[] = [];
public readonly activeWorkspaceBrowsers: Array<{
browserId: string | null;
workspaceId: string;
}> = [];
public response: BrowserAutomationResponsePayload | null = null;
public thrownError: unknown = null;
public executeAutomationCommand = async (
request: BrowserAutomationExecuteRequest,
): Promise<BrowserAutomationResponsePayload> => {
this.executedRequests.push(request);
if (this.thrownError) {
throw this.thrownError;
}
return this.response ?? currentListTabsPayload(request.requestId);
};
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
this.unregisteredWorkspaceBrowsers.push(browserId);
};
public setWorkspaceActiveBrowser = async (input: {
browserId: string | null;
workspaceId: string;
}): Promise<void> => {
this.activeWorkspaceBrowsers.push(input);
};
}
class FakeResidentBrowser {
public readonly ensuredWebviews: Array<{
browserId: string;
workspaceId: string;
url: string;
}> = [];
public ensure = (input: {
browserId: string;
workspaceId: string;
url: string;
}): HTMLElement | null => {
this.ensuredWebviews.push(input);
return null;
};
}
class BrowserAutomationHandlerHarness {
public readonly client = new FakeDaemonClient();
public readonly browser = new FakeBrowserBridge();
public readonly resident = new FakeResidentBrowser();
private unsubscribe: (() => void) | null = null;
public mount(
input: {
serverId?: string;
host?: DesktopHostBridge | null;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
} = {},
): void {
this.unsubscribe = mountBrowserAutomationHandler({
client: this.client,
...(input.serverId ? { serverId: input.serverId } : {}),
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
ensureResidentBrowserWebview: this.resident.ensure,
...(input.registrationWaitTimeoutMs !== undefined
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
: {}),
...(input.registrationPollIntervalMs !== undefined
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
: {}),
});
}
public unmount(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
public receive(request: BrowserAutomationExecuteRequest): void {
this.client.receive(request);
}
}
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-1",
command: { command: "list_tabs", args: {} },
};
}
function browserNewTabRequest(): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-new",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: {
command: "new_tab",
args: { url: "https://example.com" },
},
};
}
function browserResizeRequest(
browserId: string,
input: { workspaceId?: string } = {},
): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-resize",
agentId: "agent-1",
workspaceId: input.workspaceId ?? "wks_workspace_a",
command: {
command: "resize",
args: { browserId, width: 1024, height: 768 },
},
};
}
function browserCloseTabRequest(browserId: string): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-close-tab",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: {
command: "close_tab",
args: { browserId },
},
};
}
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
return {
requestId,
ok: true,
result: {
command: "list_tabs",
tabs: [],
},
};
}
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
return {
requestId,
ok: true,
result: {
command: "list_tabs",
tabs: currentBrowserTabs(),
},
};
}
function currentBrowserTabs() {
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
browserId: browser.browserId,
workspaceId: "wks_workspace_a",
url: browser.url,
title: browser.title,
isActive: true,
isLoading: false,
}));
}
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
expect(payload).toMatchObject({
requestId: "req-new",
ok: true,
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
});
if (!payload.ok || payload.result.command !== "new_tab") {
throw new Error("Expected browser_new_tab success payload");
}
return payload.result;
}
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
return useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function waitForRegistrationTimeout(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
const browserAutomationStorage = new FakeStateStorage();
useBrowserStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
useWorkspaceLayoutStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
describe("mountBrowserAutomationHandler", () => {
beforeEach(() => {
browserAutomationStorage.clear();
useBrowserStore.setState({ browsersById: {} });
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
});
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "wks_workspace_a",
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
const previousFocusedTabId = useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(openedTabs).toEqual([
expect.objectContaining({
target: { kind: "browser", browserId: result.browserId },
}),
]);
expect(layout?.root).toEqual(
expect.objectContaining({
kind: "pane",
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
}),
);
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
expect(browser.resident.ensuredWebviews).toEqual([
{
browserId: result.browserId,
workspaceId: "wks_workspace_a",
url: "https://example.com",
},
]);
expect(browser.browser.executedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-new:list_tabs",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: { command: "list_tabs", args: {} },
},
]);
});
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = emptyListTabsPayload();
browser.mount({
serverId: "server-1",
registrationWaitTimeoutMs: 1,
registrationPollIntervalMs: 1,
});
browser.receive(browserNewTabRequest());
await waitForRegistrationTimeout();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
message: expect.stringContaining("Timed out waiting for browser tab"),
retryable: true,
},
},
},
]);
expect(browser.resident.ensuredWebviews).toEqual([
expect.objectContaining({
workspaceId: "wks_workspace_a",
url: "https://example.com",
}),
]);
});
test("browser_new_tab wraps registration bridge errors in a response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error("IPC registration check failed");
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_unknown_error",
message: "IPC registration check failed",
retryable: false,
},
},
},
]);
});
test("browser_resize updates resident webview dimensions", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserResizeRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-resize",
ok: true,
result: {
command: "resize",
browserId: result.browserId,
width: 1024,
height: 768,
},
});
expect(browser.browser.executedRequests).toHaveLength(1);
});
test("browser_resize returns not found for a tab outside the request workspace", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserResizeRequest(result.browserId, { workspaceId: "wks_workspace_b" }));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-resize",
ok: false,
error: {
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
retryable: false,
},
});
});
test("browser_close_tab removes the workspace tab, browser record, resident webview, and registry entry", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "wks_workspace_a",
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-close-tab",
ok: true,
result: { command: "close_tab", browserId: result.browserId },
});
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
expect(currentBrowserTabs()).toEqual([]);
});
test("browser_close_tab returns not found after the tab is gone", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(2)).toEqual({
requestId: "req-close-tab",
ok: false,
error: {
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
retryable: false,
},
});
});
test("non-new-tab requests send the desktop bridge response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = {
requestId: "desktop-req",
ok: true,
result: { command: "list_tabs", tabs: [] },
};
browser.mount();
const request = browserAutomationRequest();
browser.receive(request);
await flushAsyncWork();
expect(browser.browser.executedRequests).toEqual([request]);
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: true,
result: { command: "list_tabs", tabs: [] },
},
},
]);
});
test("missing desktop bridge sends browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ host: null });
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Browser automation is not available in this app runtime.",
retryable: false,
},
},
},
]);
});
test("typed bridge errors become failure responses", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
};
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
},
},
},
]);
});
test("unimplemented preload IPC reports browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error(
'No handler registered for "paseo:browser:execute-automation-command"',
);
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Browser automation is not implemented by this app build yet.",
retryable: false,
},
},
},
]);
});
test("unsubscribe stops handling browser automation requests", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount();
browser.unmount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.browser.executedRequests).toEqual([]);
expect(browser.client.sentResponses).toEqual([]);
});
});
@@ -0,0 +1,466 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
import {
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
removeResidentBrowserWebview,
resizeResidentBrowserWebview,
} from "@/desktop/browser/resident-webviews";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/desktop/browser/store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
type BrowserAutomationFailurePayload = Extract<BrowserAutomationResponsePayload, { ok: false }>;
type BrowserAutomationErrorCode = BrowserAutomationFailurePayload["error"]["code"];
interface BrowserAutomationClient {
on(
type: "browser.automation.execute.request",
handler: (message: BrowserAutomationExecuteRequest) => void,
): () => void;
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void;
}
export interface BrowserAutomationHandlerOptions {
client: BrowserAutomationClient;
serverId?: string;
getHost?: () => DesktopHostBridge | null;
ensureResidentBrowserWebview?: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}
export function mountBrowserAutomationHandler(
options: BrowserAutomationHandlerOptions,
): () => void {
const getHost = options.getHost ?? getDesktopHost;
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
void handleBrowserAutomationRequest({
client: options.client,
getHost,
request,
serverId: options.serverId,
ensureResidentBrowserWebview:
options.ensureResidentBrowserWebview ?? ensureResidentBrowserWebviewDefault,
...(options.registrationWaitTimeoutMs !== undefined
? { registrationWaitTimeoutMs: options.registrationWaitTimeoutMs }
: {}),
...(options.registrationPollIntervalMs !== undefined
? { registrationPollIntervalMs: options.registrationPollIntervalMs }
: {}),
});
});
return () => {
unsubscribe();
};
}
export function mountBrowserAutomationDaemonClientHandler(
client: unknown,
options?: { serverId?: string },
): () => void {
return mountBrowserAutomationHandler({
client: client as BrowserAutomationClient,
...(options?.serverId ? { serverId: options.serverId } : {}),
});
}
async function handleBrowserAutomationRequest(params: {
client: BrowserAutomationHandlerOptions["client"];
getHost: () => DesktopHostBridge | null;
request: BrowserAutomationExecuteRequest;
serverId?: string;
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}): Promise<void> {
const {
client,
getHost,
request,
serverId,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs,
registrationPollIntervalMs,
} = params;
const browserHost = getHost()?.browser;
const executeAutomationCommand = browserHost?.executeAutomationCommand;
if (request.command.command === "new_tab") {
try {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: await openBrowserTabForRequest({
request,
serverId,
browserHost,
ensureResidentBrowserWebview,
...(registrationWaitTimeoutMs !== undefined ? { registrationWaitTimeoutMs } : {}),
...(registrationPollIntervalMs !== undefined ? { registrationPollIntervalMs } : {}),
}),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
return;
}
if (request.command.command === "resize") {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: resizeBrowserTabForRequest({ request, serverId }),
});
return;
}
if (request.command.command === "close_tab") {
try {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: await closeBrowserTabForRequest({
request,
serverId,
browserHost,
}),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
return;
}
if (!executeAutomationCommand) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Browser automation is not available in this app runtime.",
}),
});
return;
}
try {
const payload = await executeAutomationCommand(request);
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeBridgePayload(request.requestId, payload),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
}
function resizeBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
}): BrowserAutomationResponsePayload {
const { request, serverId } = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "resize" }
>;
const browserId = command.args.browserId;
if (!getBrowserRecord(browserId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
const workspaceId = request.workspaceId;
if (serverId && workspaceId && !findWorkspaceBrowserTab({ serverId, workspaceId, browserId })) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
const dimensions = resizeResidentBrowserWebview({
browserId,
width: command.args.width,
height: command.args.height,
});
if (!dimensions) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
return {
requestId: request.requestId,
ok: true,
result: {
command: "resize",
browserId,
width: dimensions.width,
height: dimensions.height,
},
};
}
async function closeBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
browserHost: DesktopHostBridge["browser"] | undefined;
}): Promise<BrowserAutomationResponsePayload> {
const { request, serverId, browserHost } = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "close_tab" }
>;
const browserId = command.args.browserId;
const workspaceId = request.workspaceId;
const workspaceTab = serverId
? findWorkspaceBrowserTab({ serverId, workspaceId, browserId })
: null;
if (!workspaceTab && (!serverId || !workspaceId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Cannot close a browser tab without a workspace context.",
});
}
if (!workspaceTab || !getBrowserRecord(browserId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
useWorkspaceLayoutStore.getState().closeTab(workspaceTab.workspaceKey, workspaceTab.tabId);
useBrowserStore.getState().removeBrowser(browserId);
removeResidentBrowserWebview(browserId);
await browserHost?.unregisterWorkspaceBrowser?.(browserId);
return {
requestId: request.requestId,
ok: true,
result: { command: "close_tab", browserId },
};
}
function findWorkspaceBrowserTab(input: {
serverId: string;
workspaceId: string | undefined;
browserId: string;
}): { workspaceKey: string; tabId: string } | null {
if (!input.workspaceId) {
return null;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (!workspaceKey) {
return null;
}
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tab = layout
? collectAllTabs(layout.root).find((candidate) => {
return (
candidate.target.kind === "browser" && candidate.target.browserId === input.browserId
);
})
: null;
return tab ? { workspaceKey, tabId: tab.tabId } : null;
}
async function openBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
browserHost: DesktopHostBridge["browser"] | undefined;
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}): Promise<BrowserAutomationResponsePayload> {
const {
request,
serverId,
browserHost,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs,
registrationPollIntervalMs,
} = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "new_tab" }
>;
const workspaceId = request.workspaceId;
if (!serverId || !workspaceId) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Cannot create a browser tab without a workspace context.",
});
}
const url = command.args.url ?? "https://example.com";
const { browserId, url: normalizedUrl } = createWorkspaceBrowser({ initialUrl: url });
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!workspaceKey) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Cannot create a browser tab without a workspace context.",
});
}
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
kind: "browser",
browserId,
});
if (browserHost?.executeAutomationCommand) {
ensureResidentBrowserWebview({ browserId, workspaceId, url: normalizedUrl });
const registered = await waitForBrowserRegistration({
request,
browserId,
workspaceId,
executeAutomationCommand: browserHost.executeAutomationCommand,
...(registrationWaitTimeoutMs !== undefined ? { timeoutMs: registrationWaitTimeoutMs } : {}),
...(registrationPollIntervalMs !== undefined
? { pollIntervalMs: registrationPollIntervalMs }
: {}),
});
if (!registered) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_timeout",
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
retryable: true,
});
}
}
return {
requestId: request.requestId,
ok: true,
result: { command: "new_tab", browserId, workspaceId, url: normalizedUrl },
};
}
async function waitForBrowserRegistration(params: {
request: BrowserAutomationExecuteRequest;
browserId: string;
workspaceId: string;
executeAutomationCommand: (
request: BrowserAutomationExecuteRequest,
) => Promise<BrowserAutomationResponsePayload>;
timeoutMs?: number;
pollIntervalMs?: number;
}): Promise<boolean> {
const deadline = Date.now() + (params.timeoutMs ?? 5_000);
while (Date.now() < deadline) {
const payload = await params.executeAutomationCommand({
type: "browser.automation.execute.request",
requestId: `${params.request.requestId}:list_tabs`,
agentId: params.request.agentId,
cwd: params.request.cwd,
workspaceId: params.workspaceId,
command: { command: "list_tabs", args: {} },
});
if (payload.ok && payload.result.command === "list_tabs") {
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
return true;
}
}
await delay(params.pollIntervalMs ?? 100);
}
return false;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeBridgePayload(
requestId: string,
payload: BrowserAutomationResponsePayload,
): BrowserAutomationResponsePayload {
return { ...payload, requestId } as BrowserAutomationResponsePayload;
}
function normalizeThrownBridgeError(
requestId: string,
error: unknown,
): BrowserAutomationFailurePayload {
const typed = readTypedBrowserAutomationError(error);
if (typed) {
return browserAutomationFailure({ requestId, ...typed });
}
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No handler registered")) {
return browserAutomationFailure({
requestId,
code: "browser_unsupported",
message: "Browser automation is not implemented by this app build yet.",
});
}
return browserAutomationFailure({
requestId,
code: "browser_unknown_error",
message: message || "Browser automation failed.",
});
}
function readTypedBrowserAutomationError(
value: unknown,
): { code: BrowserAutomationErrorCode; message: string; retryable?: boolean } | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (typeof record.code !== "string" || !record.code.startsWith("browser_")) {
return null;
}
if (typeof record.message !== "string" || record.message.length === 0) {
return null;
}
return {
code: record.code as BrowserAutomationErrorCode,
message: record.message,
...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}),
};
}
function browserAutomationFailure(params: {
requestId: string;
code: BrowserAutomationErrorCode;
message: string;
retryable?: boolean;
}): BrowserAutomationFailurePayload {
return {
requestId: params.requestId,
ok: false,
error: {
code: params.code,
message: params.message,
retryable: params.retryable ?? false,
},
};
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { createDefaultLayout } from "@/stores/workspace-layout-store";
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
return openTabInLayoutFocused({
layout: createDefaultLayout(),
target: { kind: "browser", browserId },
now: 1,
}).layout;
}
describe("browser new-tab requests", () => {
it("accepts desktop requests from browser tabs in the current workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toEqual<BrowserNewTabRequest>({
sourceBrowserId: "browser-1",
url: "https://example.com/target",
});
});
it("ignores desktop requests from another workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-from-other-workspace",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
it("rejects unsupported desktop request URLs", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "file:///etc/passwd",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
});
@@ -0,0 +1,99 @@
import { useEffect } from "react";
import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host";
import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import { getIsElectron } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent;
function isAllowedBrowserNewTabUrl(value: string): boolean {
try {
const parsed = new URL(value);
return (
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
);
} catch {
return false;
}
}
function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null {
if (!payload || typeof payload !== "object") {
return null;
}
const candidate = payload as Partial<BrowserNewTabRequest>;
if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) {
return null;
}
if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) {
return null;
}
return {
sourceBrowserId: candidate.sourceBrowserId,
url: candidate.url,
};
}
function workspaceContainsBrowser(input: {
workspaceLayout: WorkspaceLayout | null | undefined;
browserId: string;
}): boolean {
if (!input.workspaceLayout) {
return false;
}
return collectAllTabs(input.workspaceLayout.root).some((tab) => {
return tab.target.kind === "browser" && tab.target.browserId === input.browserId;
});
}
export function resolveBrowserNewTabRequest(input: {
payload: unknown;
workspaceLayout: WorkspaceLayout | null | undefined;
}): BrowserNewTabRequest | null {
const request = readDesktopBrowserNewTabRequest(input.payload);
if (!request) {
return null;
}
if (
!workspaceContainsBrowser({
workspaceLayout: input.workspaceLayout,
browserId: request.sourceBrowserId,
})
) {
return null;
}
return request;
}
export function useDesktopBrowserNewTabRequests(input: {
enabled: boolean;
workspaceLayout: WorkspaceLayout | null | undefined;
openUrl: (url: string) => void;
}): void {
const handleNewTabRequest = useStableEvent((payload: unknown) => {
const request = resolveBrowserNewTabRequest({
payload,
workspaceLayout: input.workspaceLayout,
});
if (!request) {
return;
}
input.openUrl(request.url);
});
useEffect(() => {
if (!input.enabled || !getIsElectron()) {
return;
}
const unsubscribe = getDesktopHost()?.events?.on?.(
"browser-new-tab-request",
handleNewTabRequest,
);
if (typeof unsubscribe === "function") {
return unsubscribe;
}
return () => {
void unsubscribe?.then((dispose) => dispose());
};
}, [handleNewTabRequest, input.enabled]);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
interface BrowserPaneProps {
browserId: string;
serverId: string;
workspaceId: string;
cwd: string | null;
isInteractive?: boolean;
onFocusPane?: () => void;
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.container}>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</View>
);
}
const styles = StyleSheet.create(() => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
},
title: {
fontSize: 16,
fontWeight: "600",
},
subtitle: {
fontSize: 12,
},
}));
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface BrowserPaneProps {
browserId: string;
serverId: string;
workspaceId: string;
cwd: string | null;
isInteractive?: boolean;
onFocusPane?: () => void;
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.container}>
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
</View>
);
}
const styles = StyleSheet.create(() => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
},
title: {
fontSize: 16,
fontWeight: "600",
},
subtitle: {
fontSize: 12,
},
}));
@@ -0,0 +1,78 @@
import { useMemo } from "react";
import { Image } from "react-native";
import { Globe } from "lucide-react-native";
import invariant from "tiny-invariant";
import { BrowserPane } from "@/desktop/browser/pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
import { useBrowserStore } from "@/desktop/browser/store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
function getBrowserLabel(input: { title: string; url: string }): string {
const title = input.title.trim();
if (title) {
return title;
}
try {
const parsed = new URL(input.url);
return parsed.hostname || input.url;
} catch {
return input.url;
}
}
function createBrowserTabIcon(faviconUrl: string | null) {
return function BrowserTabIcon({ size, color }: PanelIconProps) {
const source = useMemo(() => (faviconUrl ? { uri: faviconUrl } : undefined), []);
const imageStyle = useMemo(() => ({ width: size, height: size, borderRadius: 3 }), [size]);
if (faviconUrl) {
return <Image accessibilityIgnoresInvertColors source={source} style={imageStyle} />;
}
return <Globe size={size} color={color} />;
};
}
function useBrowserPanelDescriptor(target: {
kind: "browser";
browserId: string;
}): PanelDescriptor {
const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null);
const url = browser?.url ?? "https://example.com";
const icon = createBrowserTabIcon(browser?.faviconUrl ?? null);
const label = getBrowserLabel({ title: browser?.title ?? "", url });
return {
label,
subtitle: url,
tooltip: url || label,
titleState: "ready",
icon,
statusBucket: browser?.isLoading ? "running" : null,
};
}
function BrowserPanel() {
const { serverId, workspaceId, target } = usePaneContext();
const { focusPane, isInteractive } = usePaneFocus();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
invariant(target.kind === "browser", "BrowserPanel requires browser target");
return (
<BrowserPane
browserId={target.browserId}
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
isInteractive={isInteractive}
onFocusPane={focusPane}
/>
);
}
export const browserPanelRegistration: PanelRegistration<"browser"> = {
kind: "browser",
component: BrowserPanel,
useDescriptor: useBrowserPanelDescriptor,
};
@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type BrowserWebviewProfileHost,
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
getResidentBrowserWebview,
prepareBrowserWebview,
releaseResidentBrowserWebview,
removeResidentBrowserWebview,
takeResidentBrowserWebview,
} from "./resident-webviews";
import {
setCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "../../utils/command-center-focus-restore";
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
const attachedBrowsers: Array<{
browserId: string;
workspaceId: string;
webContentsId: number;
}> = [];
const profileHost: BrowserWebviewProfileHost = {
profilePartition: "persist:paseo-browser",
registerAttachedBrowser: async (input) => {
attachedBrowsers.push(input);
},
};
function ensureTestBrowser(input: {
browserId: string;
workspaceId: string;
url: string;
}): HTMLElement | null {
return ensureResidentBrowserWebview({ ...input, profileHost });
}
function prepareTestBrowser(
webview: HTMLElement,
input: { browserId: string; workspaceId: string; initialUrl?: string | null },
): void {
prepareBrowserWebview(webview, { ...input, profileHost });
}
function residentHost(): HTMLElement {
const host = document.getElementById(RESIDENT_HOST_ID);
if (!host) {
throw new Error("Expected resident browser host");
}
return host;
}
function expectPermanentHostParking(host: HTMLElement): void {
expect(host.style.position).toBe("fixed");
expect(host.style.left).toBe("0px");
expect(host.style.top).toBe("0px");
expect(host.style.width).toBe("1px");
expect(host.style.height).toBe("1px");
expect(host.style.overflow).toBe("hidden");
expect(host.style.opacity).toBe("1");
expect(host.style.pointerEvents).toBe("none");
expect(host.style.display).toBe("block");
expect(host.style.visibility).toBe("visible");
expect(host.style.transform).toBe("");
}
function expectResidentWebviewParking(webview: HTMLElement): void {
expect(webview.style.display).toBe("inline-flex");
expect(webview.style.flex).toBe("0 0 auto");
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("0");
}
describe("resident browser webviews", () => {
beforeEach(() => {
attachedBrowsers.length = 0;
});
afterEach(() => {
clearResidentBrowserWebviewsForTests();
});
it("parks a browser webview in the permanent paintable 1x1 host", () => {
const visibleHost = document.createElement("div");
const webview = document.createElement("webview");
visibleHost.appendChild(webview);
document.body.appendChild(visibleHost);
releaseResidentBrowserWebview("browser-a", webview);
const host = residentHost();
expect(visibleHost.children).toHaveLength(0);
expect(Array.from(host.children)).toEqual([webview]);
expect(webview.isConnected).toBe(true);
expectPermanentHostParking(host);
expectResidentWebviewParking(webview);
});
it("creates a resident webview for an agent-created unfocused tab", () => {
const webview = ensureTestBrowser({
browserId: "browser-agent",
workspaceId: "workspace-agent",
url: "https://example.com",
});
expect(webview).not.toBeNull();
expect(webview?.isConnected).toBe(true);
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser");
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
"https://example.com",
);
expectPermanentHostParking(residentHost());
expectResidentWebviewParking(webview as HTMLElement);
});
it("shares one profile and registers attached guests with explicit identity", () => {
const firstWebview = ensureTestBrowser({
browserId: "browser-first",
workspaceId: "workspace-a",
url: "https://example.com/first",
});
const secondWebview = ensureTestBrowser({
browserId: "browser-second",
workspaceId: "workspace-b",
url: "https://example.com/second",
});
if (!firstWebview || !secondWebview) {
throw new Error("Expected resident webviews");
}
Object.assign(firstWebview, { getWebContentsId: () => 101 });
Object.assign(secondWebview, { getWebContentsId: () => 202 });
firstWebview.dispatchEvent(new Event("did-attach"));
secondWebview.dispatchEvent(new Event("did-attach"));
expect(firstWebview.getAttribute("partition")).toBe("persist:paseo-browser");
expect(secondWebview.getAttribute("partition")).toBe("persist:paseo-browser");
expect(attachedBrowsers).toEqual([
{ browserId: "browser-first", workspaceId: "workspace-a", webContentsId: 101 },
{ browserId: "browser-second", workspaceId: "workspace-b", webContentsId: 202 },
]);
});
it("normalizes an existing resident host back to permanent parking", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
staleHost.style.left = "-20000px";
staleHost.style.width = "1280px";
staleHost.style.height = "800px";
staleHost.style.opacity = "0";
staleHost.style.display = "none";
document.body.appendChild(staleHost);
const webview = ensureTestBrowser({
browserId: "browser-stale-host",
workspaceId: "workspace-stale-host",
url: "https://example.com",
});
expect(webview).not.toBeNull();
expectPermanentHostParking(staleHost);
expectResidentWebviewParking(webview as HTMLElement);
});
it("normalizes an existing resident webview and its stale host before reusing them", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
staleHost.style.left = "-20000px";
staleHost.style.width = "1280px";
staleHost.style.height = "800px";
staleHost.style.opacity = "0";
staleHost.style.display = "none";
const staleWebview = document.createElement("webview");
prepareTestBrowser(staleWebview, {
browserId: "browser-stale-child",
workspaceId: "workspace-stale-child",
initialUrl: "https://example.com",
});
staleWebview.style.display = "none";
staleWebview.style.width = "1px";
staleWebview.style.height = "1px";
staleWebview.style.position = "fixed";
staleWebview.style.left = "-20000px";
staleHost.appendChild(staleWebview);
document.body.appendChild(staleHost);
const webview = ensureTestBrowser({
browserId: "browser-stale-child",
workspaceId: "workspace-stale-child",
url: "https://example.com/agent",
});
expect(webview).toBe(staleWebview);
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
expectPermanentHostParking(staleHost);
expectResidentWebviewParking(staleWebview);
});
it("parks resident webviews as an overlapping stack", () => {
const firstWebview = ensureTestBrowser({
browserId: "browser-first",
workspaceId: "workspace-stack",
url: "https://example.com/first",
});
const secondWebview = ensureTestBrowser({
browserId: "browser-second",
workspaceId: "workspace-stack",
url: "https://example.com/second",
});
const host = residentHost();
expect(firstWebview?.parentElement).toBe(host);
expect(secondWebview?.parentElement).toBe(host);
expectResidentWebviewParking(firstWebview as HTMLElement);
expectResidentWebviewParking(secondWebview as HTMLElement);
});
it("moves a resident webview into a visible pane without recreating the node", () => {
const webview = ensureTestBrowser({
browserId: "browser-visible",
workspaceId: "workspace-visible",
url: "https://example.com",
});
const visibleWebview = takeResidentBrowserWebview("browser-visible");
expect(visibleWebview).toBe(webview);
expect(webview?.style.position).toBe("");
expect(webview?.style.left).toBe("");
expect(webview?.style.top).toBe("");
expect(webview?.style.zIndex).toBe("");
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
});
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
const visibleHost = document.createElement("div");
const visibleWebview = document.createElement("webview");
prepareTestBrowser(visibleWebview, {
browserId: "browser-visible-pane",
workspaceId: "workspace-visible-pane",
initialUrl: "https://example.com",
});
visibleHost.appendChild(visibleWebview);
document.body.appendChild(visibleHost);
const webview = ensureTestBrowser({
browserId: "browser-visible-pane",
workspaceId: "workspace-visible-pane",
url: "https://example.com/agent",
});
expect(webview).toBe(visibleWebview);
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
});
it("finds the originating browser webview for focus restoration", () => {
const webview = ensureTestBrowser({
browserId: "browser-focus",
workspaceId: "workspace-focus",
url: "https://example.com",
});
setCommandCenterFocusRestoreElement(getResidentBrowserWebview("browser-focus"));
expect(takeCommandCenterFocusRestoreElement()).toBe(webview);
expect(getResidentBrowserWebview("browser-missing")).toBeNull();
});
it("removes a resident webview when its browser tab closes", () => {
const webview = ensureTestBrowser({
browserId: "browser-closed",
workspaceId: "workspace-closed",
url: "https://example.com",
});
removeResidentBrowserWebview("browser-closed");
expect(webview?.isConnected).toBe(false);
expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
});
});
@@ -0,0 +1,313 @@
import {
getDesktopHost,
type DesktopAttachedBrowserRegistration,
type DesktopBrowserBridge,
} from "@/desktop/host";
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
interface BrowserWebviewElement extends HTMLElement {
src: string;
getWebContentsId(): number;
}
interface BrowserWebviewIdentity {
browserId: string;
workspaceId: string;
}
export interface BrowserWebviewProfileHost {
profilePartition: string;
registerAttachedBrowser(input: DesktopAttachedBrowserRegistration): Promise<void>;
}
function isAttachedBrowserBridge(
browser: DesktopBrowserBridge | undefined,
): browser is BrowserWebviewProfileHost {
return (
browser !== undefined &&
typeof browser.profilePartition === "string" &&
browser.profilePartition.startsWith("persist:") &&
typeof browser.registerAttachedBrowser === "function"
);
}
function getBrowserBridge(override?: BrowserWebviewProfileHost): BrowserWebviewProfileHost {
if (override) {
return override;
}
const browser = getDesktopHost()?.browser;
if (!isAttachedBrowserBridge(browser)) {
throw new Error("Electron browser profile bridge is unavailable");
}
return browser;
}
function registerBrowserWhenAttached(
webview: BrowserWebviewElement,
identity: BrowserWebviewIdentity,
browser: BrowserWebviewProfileHost,
): void {
// Reparenting a webview can replace its guest WebContents without replacing
// this DOM element, so every attachment needs a fresh main-process registration.
webview.addEventListener("did-attach", () => {
const webContentsId = webview.getWebContentsId();
void browser
.registerAttachedBrowser({
browserId: identity.browserId,
workspaceId: identity.workspaceId,
webContentsId,
})
.catch((error) => {
console.error("[browser-webview] attached registration failed", error);
});
});
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readDocument(): Document | null {
return typeof document === "undefined" ? null : document;
}
function applyResidentHostParkingStyle(host: HTMLElement): void {
// Parked browser webviews must remain paintable at all times; screenshot
// correctness depends on the proven states in docs/browser-capture-harness.md.
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.display = "block";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "visible";
host.style.transform = "";
}
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
if (existing) {
applyResidentHostParkingStyle(existing);
return existing;
}
const host = ownerDocument.createElement("div");
host.id = RESIDENT_BROWSER_HOST_ID;
applyResidentHostParkingStyle(host);
ownerDocument.body.appendChild(host);
return host;
}
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
return element;
}
}
return null;
}
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
if (!browserId) {
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
}
return (
residentWebviewSizesByBrowserId.get(browserId) ?? {
width: RESIDENT_VIEWPORT_WIDTH,
height: RESIDENT_VIEWPORT_HEIGHT,
}
);
}
function applyResidentWebviewStyle(webview: HTMLElement, browserId: string | null): void {
const dimensions = dimensionsForBrowser(browserId);
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.width = `${dimensions.width}px`;
webview.style.height = `${dimensions.height}px`;
webview.style.border = "0";
webview.style.background = "transparent";
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = "0";
}
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = "";
webview.style.zIndex = "";
}
export function prepareBrowserWebview(
webview: HTMLElement,
input: {
browserId: string;
workspaceId: string;
initialUrl?: string | null;
profileHost?: BrowserWebviewProfileHost;
},
): void {
const browser = getBrowserBridge(input.profileHost);
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
webview.setAttribute("partition", browser.profilePartition);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
if (input.initialUrl) {
(webview as BrowserWebviewElement).src = input.initialUrl;
}
registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser);
}
export function ensureResidentBrowserWebview(input: {
browserId: string;
workspaceId: string;
url: string;
profileHost?: BrowserWebviewProfileHost;
}): HTMLElement | null {
const browserId = trimNonEmpty(input.browserId);
if (!browserId) {
return null;
}
const ownerDocument = readDocument();
if (!ownerDocument) {
return null;
}
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
if (resident?.isConnected) {
releaseResidentBrowserWebview(browserId, resident);
return resident;
}
const existing = findBrowserWebview(browserId, ownerDocument);
if (existing) {
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
releaseResidentBrowserWebview(browserId, existing);
}
return existing;
}
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
prepareBrowserWebview(webview, {
browserId,
workspaceId: input.workspaceId,
initialUrl: input.url,
profileHost: input.profileHost,
});
releaseResidentBrowserWebview(browserId, webview);
return webview;
}
export function getResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
if (resident?.isConnected) {
return resident;
}
const ownerDocument = readDocument();
return ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
}
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
const webview = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
if (!webview) {
return null;
}
residentWebviewsByBrowserId.delete(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview);
return webview;
}
export function releaseResidentBrowserWebview(browserId: string, webview: HTMLElement): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
webview.remove();
return;
}
const ownerDocument = readDocument();
if (!ownerDocument) {
return;
}
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
applyResidentWebviewStyle(webview, normalizedBrowserId);
getResidentBrowserHost(ownerDocument).appendChild(webview);
}
export function resizeResidentBrowserWebview(input: {
browserId: string;
width: number;
height: number;
}): { width: number; height: number } | null {
const normalizedBrowserId = trimNonEmpty(input.browserId);
if (!normalizedBrowserId) {
return null;
}
const width = Math.max(1, Math.round(input.width));
const height = Math.max(1, Math.round(input.height));
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
const ownerDocument = readDocument();
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
if (webview) {
webview.style.width = `${width}px`;
webview.style.height = `${height}px`;
}
return { width, height };
}
export function removeResidentBrowserWebview(browserId: string): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return;
}
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId);
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
resident?.remove();
}
export function clearResidentBrowserWebviewsForTests(): void {
for (const webview of residentWebviewsByBrowserId.values()) {
webview.remove();
}
residentWebviewsByBrowserId.clear();
residentWebviewSizesByBrowserId.clear();
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
}
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import { getDesktopHost } from "@/desktop/host";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useBrowserStore } from "@/stores/browser-store";
import { useBrowserStore } from "@/desktop/browser/store";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -0,0 +1,322 @@
import { describe, expect, it } from "vitest";
import {
buildBrowserKeyboardPolicy,
parseBrowserShortcutInput,
shouldPublishBrowserShortcutPolicy,
} from "./shortcuts";
import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard/keyboard-shortcuts";
describe("buildBrowserKeyboardPolicy", () => {
it("publishes only chord starts while no browser chord is pending", () => {
const bindings = buildEffectiveBindings({
"workspace-tab-new-ctrl-t-non-mac": "Ctrl+Y",
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+F12 Ctrl+F11",
});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
expect(policy.prefixes).toContainEqual({
alt: false,
code: "KeyY",
control: true,
key: "y",
meta: false,
shift: false,
});
expect(policy.prefixes).toContainEqual({
alt: false,
code: "F12",
control: true,
meta: false,
shift: false,
});
expect(policy.prefixes).not.toContainEqual({
alt: false,
code: "F11",
control: true,
meta: false,
shift: false,
});
});
it("publishes a chord continuation only after its browser start crosses the boundary", () => {
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+F12 Ctrl+F11",
});
const chordIndex = bindings.findIndex(
(binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac",
);
const policy = buildBrowserKeyboardPolicy({
bindings,
chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null },
isMac: false,
isDesktop: true,
});
expect(policy.prefixes).toEqual([
{
alt: false,
code: "F11",
control: true,
meta: false,
shift: false,
},
]);
expect(policy.menuPrefixes).toContainEqual({
alt: false,
code: "KeyW",
control: true,
key: "w",
meta: false,
shift: false,
});
const result = resolveKeyboardShortcut({
event: {
altKey: false,
code: "F11",
ctrlKey: true,
key: "F11",
metaKey: false,
repeat: false,
shiftKey: false,
},
context: {
commandCenterOpen: false,
focusScope: "browser",
isDesktop: true,
isMac: false,
},
chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null },
onChordReset: () => undefined,
bindings,
});
expect(result.match?.action).toBe("workspace.terminal.new");
});
it("rejects an entire chord when a continuation cannot cross the browser boundary", () => {
const bindings = buildEffectiveBindings({
"settings-toggle-ctrl-comma-non-mac": "Ctrl+F10 F9",
});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
expect(policy.prefixes).not.toContainEqual({
alt: false,
code: "F10",
control: true,
meta: false,
shift: false,
});
expect(policy.prefixes).not.toContainEqual({
alt: false,
code: "F9",
control: false,
meta: false,
shift: false,
});
});
it("publishes Mod bindings for the current shortcut platform", () => {
const bindings = buildEffectiveBindings({
"workspace-tab-new-cmd-t-mac": "Mod+Y",
});
expect(
buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }).prefixes,
).toContainEqual({
alt: false,
code: "KeyY",
control: false,
key: "y",
meta: true,
shift: false,
});
});
it("keeps Ctrl+W out of the window menu after the tab-close shortcut is remapped", () => {
const bindings = buildEffectiveBindings({
"workspace-tab-close-current-ctrl-w-non-mac": "Ctrl+Y",
});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
expect(policy.prefixes).not.toContainEqual({
alt: false,
code: "KeyW",
control: true,
key: "w",
meta: false,
shift: false,
});
expect(policy.menuPrefixes).toContainEqual({
alt: false,
code: "KeyW",
control: true,
key: "w",
meta: false,
shift: false,
});
});
it("leaves macOS browser back and forward shortcuts in the guest", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
for (const code of ["BracketLeft", "BracketRight"]) {
expect(policy.prefixes).not.toContainEqual({
alt: false,
code,
control: false,
key: code === "BracketLeft" ? "[" : "]",
meta: true,
shift: false,
});
}
});
it("marks editable-only exclusions for enforcement inside the guest", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
expect(policy.prefixes).toContainEqual({
alt: false,
code: "ArrowLeft",
control: false,
editable: false,
meta: true,
shift: true,
});
});
it("does not publish plain browser keys", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
expect(policy.prefixes).not.toContainEqual({
alt: false,
code: "Enter",
control: false,
meta: false,
shift: false,
});
expect(policy.prefixes).not.toContainEqual({
alt: false,
code: "Slash",
control: false,
meta: false,
shift: true,
});
});
it("publishes Cmd+B with its logical key for non-QWERTY layouts", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
expect(policy.prefixes).toContainEqual({
alt: false,
code: "KeyB",
control: false,
key: "b",
meta: true,
shift: false,
});
});
it("publishes the physical code needed for macOS Option shortcuts", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
expect(policy.prefixes).toContainEqual({
alt: true,
code: "KeyT",
control: false,
key: "t",
meta: true,
shift: false,
});
});
});
describe("shouldPublishBrowserShortcutPolicy", () => {
it("restores the initial browser policy when a host key resets a pending chord", () => {
expect(
shouldPublishBrowserShortcutPolicy({
isBrowserInput: false,
previousChordState: { candidateIndices: [1], step: 1, timeoutId: null },
nextChordState: { candidateIndices: [], step: 0, timeoutId: null },
}),
).toBe(true);
});
});
describe("parseBrowserShortcutInput", () => {
it("normalizes browser shortcut input without losing its identity", () => {
expect(
parseBrowserShortcutInput({
browserId: "browser-1",
key: "t",
code: "KeyT",
meta: false,
control: true,
shift: false,
alt: false,
}),
).toEqual({
browserId: "browser-1",
key: "t",
code: "KeyT",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
repeat: false,
});
});
it("keeps browser identities exact", () => {
expect(
parseBrowserShortcutInput({
browserId: " browser-1 ",
key: "t",
code: "KeyT",
meta: false,
control: true,
shift: false,
alt: false,
}),
).toMatchObject({ browserId: " browser-1 " });
});
it.each([
{
name: "a missing browser identity",
payload: {
key: "t",
code: "KeyT",
meta: false,
control: true,
shift: false,
alt: false,
},
},
{
name: "a malformed repeat flag",
payload: {
browserId: "browser-1",
key: "t",
code: "KeyT",
meta: false,
control: true,
shift: false,
alt: false,
repeat: "yes",
},
},
])("rejects $name", ({ payload }) => {
expect(parseBrowserShortcutInput(payload)).toBeNull();
});
});
@@ -0,0 +1,211 @@
import {
type ChordState,
matchesKeyboardShortcutContext,
type KeyboardShortcutInput,
type ParsedShortcutBinding,
} from "../../keyboard/keyboard-shortcuts";
import type { KeyCombo } from "../../keyboard/shortcut-string";
export interface BrowserShortcutPrefix {
alt: boolean;
code: string;
codeFallback?: true;
control: boolean;
editable?: false;
key?: string;
meta: boolean;
repeat?: false;
shift: boolean;
shiftedKey?: string;
}
export interface BrowserShortcutInput extends KeyboardShortcutInput {
browserId: string;
}
export interface BrowserKeyboardPolicy {
menuPrefixes: BrowserShortcutPrefix[];
prefixes: BrowserShortcutPrefix[];
}
interface BrowserShortcutPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
isDesktop: boolean;
}
export function shouldPublishBrowserShortcutPolicy(input: {
isBrowserInput: boolean;
nextChordState: ChordState;
previousChordState: ChordState;
}): boolean {
return (
input.isBrowserInput || (input.previousChordState.step > 0 && input.nextChordState.step === 0)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function parseBrowserShortcutInput(value: unknown): BrowserShortcutInput | null {
if (!isRecord(value)) {
return null;
}
const { browserId, code, key } = value;
if (typeof browserId !== "string" || browserId.length === 0) {
return null;
}
if (typeof code !== "string" || typeof key !== "string") {
return null;
}
if (
typeof value.alt !== "boolean" ||
typeof value.control !== "boolean" ||
typeof value.meta !== "boolean" ||
typeof value.shift !== "boolean" ||
(value.repeat !== undefined && typeof value.repeat !== "boolean")
) {
return null;
}
return {
browserId,
key,
code,
altKey: value.alt,
ctrlKey: value.control,
metaKey: value.meta,
shiftKey: value.shift,
repeat: value.repeat ?? false,
};
}
function prefixFromCombo(
combo: KeyCombo,
isMac: boolean,
editable: false | undefined,
): BrowserShortcutPrefix | null {
const prefix: BrowserShortcutPrefix = {
alt: combo.alt === true,
code: combo.code,
control: combo.ctrl === true || (!isMac && combo.mod === true),
meta: combo.meta === true || (isMac && combo.mod === true),
shift: combo.shift === true,
};
if (combo.codeFallback === true) {
prefix.codeFallback = true;
}
if (editable === false) {
prefix.editable = false;
}
if (combo.key) {
prefix.key = combo.key;
}
if (combo.repeat === false) {
prefix.repeat = false;
}
if (combo.shiftedKey) {
prefix.shiftedKey = combo.shiftedKey;
}
return prefix.meta || prefix.control || prefix.alt ? prefix : null;
}
function isBrowserNativeNavigationPrefix(prefix: BrowserShortcutPrefix, isMac: boolean): boolean {
return (
isMac &&
prefix.meta &&
!prefix.control &&
!prefix.alt &&
!prefix.shift &&
(prefix.code === "BracketLeft" || prefix.code === "BracketRight")
);
}
function canCrossBrowserBoundary(binding: ParsedShortcutBinding, isMac: boolean): boolean {
return binding.parsedChord.every((combo) => {
const prefix = prefixFromCombo(combo, isMac, binding.when?.editable);
return prefix !== null && !isBrowserNativeNavigationPrefix(prefix, isMac);
});
}
function prefixKey(prefix: BrowserShortcutPrefix): string {
return [
prefix.code,
prefix.key ?? "",
prefix.shiftedKey ?? "",
prefix.codeFallback ?? "",
prefix.editable ?? "",
prefix.control,
prefix.meta,
prefix.alt,
prefix.shift,
prefix.repeat ?? "",
].join(":");
}
function buildBrowserShortcutPrefixes(input: BrowserShortcutPolicyInput): BrowserShortcutPrefix[] {
const prefixes = new Map<string, BrowserShortcutPrefix>();
const context = {
isMac: input.isMac,
isDesktop: input.isDesktop,
focusScope: "browser" as const,
commandCenterOpen: false,
};
const candidates =
input.chordState && input.chordState.step > 0
? input.chordState.candidateIndices
: input.bindings.map((_, index) => index);
const step = input.chordState?.step ?? 0;
for (const index of candidates) {
const binding = input.bindings[index];
if (!binding) {
continue;
}
if (!matchesKeyboardShortcutContext(binding.when, context)) {
continue;
}
if (!canCrossBrowserBoundary(binding, input.isMac)) {
continue;
}
const combo = binding.parsedChord[step];
if (!combo) {
continue;
}
const prefix = prefixFromCombo(combo, input.isMac, binding.when?.editable);
if (!prefix) {
continue;
}
prefixes.set(prefixKey(prefix), prefix);
}
return [...prefixes.values()];
}
export function buildBrowserKeyboardPolicy(
input: BrowserShortcutPolicyInput,
): BrowserKeyboardPolicy {
const idlePrefixes = buildBrowserShortcutPrefixes({ ...input, chordState: undefined });
const prefixes =
input.chordState && input.chordState.step > 0
? buildBrowserShortcutPrefixes(input)
: idlePrefixes;
const menuPrefixes = [...idlePrefixes];
if (!input.isMac) {
const closeWindowGuard: BrowserShortcutPrefix = {
alt: false,
code: "KeyW",
control: true,
key: "w",
meta: false,
shift: false,
};
if (!menuPrefixes.some((prefix) => prefixKey(prefix) === prefixKey(closeWindowGuard))) {
menuPrefixes.push(closeWindowGuard);
}
}
return { menuPrefixes, prefixes };
}
@@ -0,0 +1,94 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
applyBrowserPatch,
type BrowserIndexState,
type BrowserRecord,
type BrowserRecordPatch,
createBrowserRecord,
normalizeBrowserUrl,
removeBrowserFromIndex,
sanitizeBrowsersForPersist,
trimNonEmpty,
} from "./state";
export type { BrowserRecord } from "./state";
interface BrowserStoreState extends BrowserIndexState {
createBrowser: (input?: { initialUrl?: string }) => string;
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
removeBrowser: (browserId: string) => void;
}
function createBrowserId(): string {
let browserId: string;
if (typeof globalThis.crypto?.randomUUID === "function") {
browserId = globalThis.crypto.randomUUID();
} else {
const randomSuffix = Math.random().toString(16).slice(2) || "0";
browserId = `${Date.now()}-${randomSuffix}`;
}
return BrowserAutomationBrowserIdSchema.parse(browserId);
}
export const useBrowserStore = create<BrowserStoreState>()(
persist(
(set) => ({
browsersById: {},
createBrowser: (input) => {
const browserId = createBrowserId();
const record = createBrowserRecord({
browserId,
initialUrl: input?.initialUrl,
now: Date.now(),
});
set((state) => ({
browsersById: {
...state.browsersById,
[browserId]: record,
},
}));
return browserId;
},
updateBrowser: (browserId, patch) => {
set((state) => applyBrowserPatch(state, browserId, patch));
},
removeBrowser: (browserId) => {
set((state) => removeBrowserFromIndex(state, browserId));
},
}),
{
name: "workspace-browser-store",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => sanitizeBrowsersForPersist(state),
},
),
);
export function getBrowserRecord(browserId: string): BrowserRecord | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null;
}
export function createWorkspaceBrowser(input?: { initialUrl?: string }): {
browserId: string;
url: string;
} {
const browserId = useBrowserStore.getState().createBrowser(input);
const record = getBrowserRecord(browserId);
return {
browserId,
url: record?.url ?? normalizeBrowserUrl(input?.initialUrl),
};
}
export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string {
return normalizeBrowserUrl(value);
}
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
applyBrowserPatch,
type BrowserIndexState,
createBrowserRecord,
normalizeBrowserUrl,
removeBrowserFromIndex,
sanitizeBrowsersForPersist,
} from "./state";
function withRecords(records: ReturnType<typeof createBrowserRecord>[]): BrowserIndexState {
return {
browsersById: Object.fromEntries(records.map((record) => [record.browserId, record])),
};
}
describe("normalizeBrowserUrl", () => {
it("normalizes local development hosts to http by default", () => {
expect(normalizeBrowserUrl("localhost:8081")).toBe("http://localhost:8081");
expect(normalizeBrowserUrl("localhost/path")).toBe("http://localhost/path");
expect(normalizeBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path");
expect(normalizeBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8");
expect(normalizeBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173");
});
it("normalizes public hosts to https by default", () => {
expect(normalizeBrowserUrl("example.com")).toBe("https://example.com");
expect(normalizeBrowserUrl("//example.com/path")).toBe("https://example.com/path");
});
it("keeps explicit protocols unchanged", () => {
expect(normalizeBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081");
expect(normalizeBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081");
expect(normalizeBrowserUrl("file:///tmp/example.html")).toBe("file:///tmp/example.html");
});
it("falls back to a default URL when input is blank", () => {
expect(normalizeBrowserUrl(null)).toBe("https://example.com");
expect(normalizeBrowserUrl(" ")).toBe("https://example.com");
});
});
describe("createBrowserRecord", () => {
it("normalizes the initial URL and starts with idle state", () => {
const record = createBrowserRecord({
browserId: "b1",
initialUrl: "localhost:8081",
now: 1000,
});
expect(record).toEqual({
browserId: "b1",
url: "http://localhost:8081",
title: "",
isLoading: false,
canGoBack: false,
canGoForward: false,
faviconUrl: null,
lastError: null,
createdAt: 1000,
});
});
});
describe("applyBrowserPatch", () => {
it("normalizes URL updates", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
const next = applyBrowserPatch(initial, "b1", { url: "example.com/path" });
expect(next.browsersById.b1?.url).toBe("https://example.com/path");
});
it("returns the same state reference when nothing changes", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
const next = applyBrowserPatch(initial, "b1", { url: "https://a.test", title: "" });
expect(next).toBe(initial);
});
it("returns the same state when the browser id is unknown", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
const next = applyBrowserPatch(initial, "missing", { title: "x" });
expect(next).toBe(initial);
});
it("returns the same state when the browser id is blank", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
expect(applyBrowserPatch(initial, " ", { title: "x" })).toBe(initial);
});
});
describe("removeBrowserFromIndex", () => {
it("removes the named browser", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
createBrowserRecord({ browserId: "b2", initialUrl: "https://b.test", now: 0 }),
]);
const next = removeBrowserFromIndex(initial, "b1");
expect(Object.keys(next.browsersById)).toEqual(["b2"]);
});
it("returns the same state when the browser id is unknown", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
expect(removeBrowserFromIndex(initial, "missing")).toBe(initial);
});
it("returns the same state when the browser id is blank", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
expect(removeBrowserFromIndex(initial, " ")).toBe(initial);
});
});
describe("sanitizeBrowsersForPersist", () => {
it("clears transient fields on every record", () => {
const base = createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 });
const state: BrowserIndexState = {
browsersById: {
b1: { ...base, isLoading: true, lastError: "network down" },
},
};
const persisted = sanitizeBrowsersForPersist(state);
expect(persisted.browsersById.b1?.isLoading).toBe(false);
expect(persisted.browsersById.b1?.lastError).toBe(null);
});
});
@@ -0,0 +1,130 @@
export interface BrowserRecord {
browserId: string;
url: string;
title: string;
isLoading: boolean;
canGoBack: boolean;
canGoForward: boolean;
faviconUrl: string | null;
lastError: string | null;
createdAt: number;
}
export type BrowserRecordPatch = Partial<Omit<BrowserRecord, "browserId" | "createdAt">>;
export interface BrowserIndexState {
browsersById: Record<string, BrowserRecord>;
}
export function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeBrowserUrl(value: string | null | undefined): string {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return "https://example.com";
}
if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) {
return `http://${trimmed}`;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) {
return trimmed;
}
if (trimmed.startsWith("//")) {
return `https:${trimmed}`;
}
return `https://${trimmed}`;
}
export function createBrowserRecord(input: {
browserId: string;
initialUrl: string | null | undefined;
now: number;
}): BrowserRecord {
return {
browserId: input.browserId,
url: normalizeBrowserUrl(input.initialUrl),
title: "",
isLoading: false,
canGoBack: false,
canGoForward: false,
faviconUrl: null,
lastError: null,
createdAt: input.now,
};
}
export function applyBrowserPatch<S extends BrowserIndexState>(
state: S,
browserId: string,
patch: BrowserRecordPatch,
): S {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return state;
}
const existing = state.browsersById[normalizedBrowserId];
if (!existing) {
return state;
}
const nextRecord: BrowserRecord = {
...existing,
...patch,
url: normalizeBrowserUrl(patch.url ?? existing.url),
};
if (
nextRecord.url === existing.url &&
nextRecord.title === existing.title &&
nextRecord.isLoading === existing.isLoading &&
nextRecord.canGoBack === existing.canGoBack &&
nextRecord.canGoForward === existing.canGoForward &&
nextRecord.faviconUrl === existing.faviconUrl &&
nextRecord.lastError === existing.lastError
) {
return state;
}
return {
...state,
browsersById: {
...state.browsersById,
[normalizedBrowserId]: nextRecord,
},
};
}
export function removeBrowserFromIndex<S extends BrowserIndexState>(
state: S,
browserId: string,
): S {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return state;
}
if (!state.browsersById[normalizedBrowserId]) {
return state;
}
const next = { ...state.browsersById };
delete next[normalizedBrowserId];
return { ...state, browsersById: next };
}
export function sanitizeBrowsersForPersist(state: BrowserIndexState): {
browsersById: Record<string, BrowserRecord>;
} {
return {
browsersById: Object.fromEntries(
Object.entries(state.browsersById).map(([browserId, browser]) => [
browserId,
{ ...browser, isLoading: false, lastError: null },
]),
),
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
import type { BrowserKeyboardPolicy } from "@/keyboard/browser-shortcuts";
import type { BrowserKeyboardPolicy } from "@/desktop/browser/shortcuts";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
type BrowserAutomationExecuteRequest = Extract<