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:
co-authored by
Jason Kneen
parent
34b95e7f01
commit
906205f97a
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user