Add browser pane element picker (#670)

* feat: add browser pane element picker

Port the browser pane from PR #198 and route picked elements through workspace attachments so the context follows every agent composer in a workspace.

Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>

* docs: document electron platform overlays

* fix: harden browser pane navigation

* fix: polish browser tab interactions

* fix: route browser pane focus shortcuts

---------

Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
This commit is contained in:
Mohamed Boudra
2026-05-02 19:28:40 +08:00
committed by GitHub
co-authored by Jason Kneen
parent 34b95e7f01
commit 906205f97a
35 changed files with 1990 additions and 25 deletions
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { normalizeWorkspaceBrowserUrl, useBrowserStore } from "./browser-store";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
describe("browser store", () => {
it("normalizes local development hosts to http by default", () => {
expect(normalizeWorkspaceBrowserUrl("localhost:8081")).toBe("http://localhost:8081");
expect(normalizeWorkspaceBrowserUrl("localhost/path")).toBe("http://localhost/path");
expect(normalizeWorkspaceBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path");
expect(normalizeWorkspaceBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8");
expect(normalizeWorkspaceBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173");
});
it("normalizes public hosts to https by default", () => {
expect(normalizeWorkspaceBrowserUrl("example.com")).toBe("https://example.com");
expect(normalizeWorkspaceBrowserUrl("//example.com/path")).toBe("https://example.com/path");
});
it("keeps explicit protocols unchanged", () => {
expect(normalizeWorkspaceBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081");
expect(normalizeWorkspaceBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081");
expect(normalizeWorkspaceBrowserUrl("file:///tmp/example.html")).toBe(
"file:///tmp/example.html",
);
});
it("normalizes browser URLs when creating and updating records", () => {
useBrowserStore.setState({ browsersById: {} });
const browserId = useBrowserStore.getState().createBrowser({ initialUrl: "localhost:8081" });
expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe("http://localhost:8081");
useBrowserStore.getState().updateBrowser(browserId, { url: "example.com/path" });
expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe(
"https://example.com/path",
);
});
});
+177
View File
@@ -0,0 +1,177 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export interface BrowserRecord {
browserId: string;
url: string;
title: string;
isLoading: boolean;
canGoBack: boolean;
canGoForward: boolean;
faviconUrl: string | null;
lastError: string | null;
createdAt: number;
}
type BrowserRecordPatch = Partial<Omit<BrowserRecord, "browserId" | "createdAt">>;
interface BrowserStoreState {
browsersById: Record<string, BrowserRecord>;
createBrowser: (input?: { initialUrl?: string }) => string;
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
removeBrowser: (browserId: string) => void;
}
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 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}`;
}
function createBrowserId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const useBrowserStore = create<BrowserStoreState>()(
persist(
(set) => ({
browsersById: {},
createBrowser: (input) => {
const browserId = createBrowserId();
const now = Date.now();
const initialUrl = normalizeBrowserUrl(input?.initialUrl);
set((state) => ({
browsersById: {
...state.browsersById,
[browserId]: {
browserId,
url: initialUrl,
title: "",
isLoading: false,
canGoBack: false,
canGoForward: false,
faviconUrl: null,
lastError: null,
createdAt: now,
},
},
}));
return browserId;
},
updateBrowser: (browserId, patch) => {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return;
}
set((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 {
browsersById: {
...state.browsersById,
[normalizedBrowserId]: nextRecord,
},
};
});
},
removeBrowser: (browserId) => {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return;
}
set((state) => {
if (!state.browsersById[normalizedBrowserId]) {
return state;
}
const next = { ...state.browsersById };
delete next[normalizedBrowserId];
return { browsersById: next };
});
},
}),
{
name: "workspace-browser-store",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
browsersById: Object.fromEntries(
Object.entries(state.browsersById).map(([browserId, browser]) => [
browserId,
{ ...browser, isLoading: false, lastError: null },
]),
),
}),
},
),
);
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);
}
@@ -11,6 +11,7 @@ export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| { kind: "file"; path: string }
| { kind: "setup"; workspaceId: string };