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
@@ -314,6 +314,7 @@ async function renderAgentPanel(
isWorkspaceFocused: true,
isPaneFocused: false,
isInteractive: false,
focusPane: vi.fn(),
},
) {
const AgentPanel = agentPanelRegistration.component;
@@ -462,6 +463,7 @@ describe("AgentPanel render isolation", () => {
isWorkspaceFocused: false,
isPaneFocused: true,
isInteractive: false,
focusPane: vi.fn(),
});
expect(latestComposerIsPaneFocused.current).toBe(false);
@@ -545,6 +547,7 @@ describe("AgentPanel render isolation", () => {
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
focusPane: vi.fn(),
});
const timeoutAt = Date.now() + 300;
+3
View File
@@ -1264,6 +1264,9 @@ function ActiveAgentComposer({
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
if (attachment.kind !== "review") {
return;
}
const checkout = {
serverId,
cwd: attachment.attachment.cwd,
+77
View File
@@ -0,0 +1,77 @@
import { useMemo } from "react";
import { Image } from "react-native";
import { Globe } from "lucide-react-native";
import invariant from "tiny-invariant";
import { BrowserPane } from "@/components/browser-pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
import { useBrowserStore } from "@/stores/browser-store";
import { useWorkspaceExecutionAuthority } 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);
return {
label: getBrowserLabel({ title: browser?.title ?? "", url }),
subtitle: url,
titleState: "ready",
icon,
statusBucket: browser?.isLoading ? "running" : null,
};
}
function BrowserPanel() {
const { serverId, workspaceId, target } = usePaneContext();
const { focusPane, isInteractive } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const cwd = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null;
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,
};
@@ -12,6 +12,7 @@ describe("createPaneFocusContextValue", () => {
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
focusPane: expect.any(Function),
});
expect(
createPaneFocusContextValue({
@@ -22,6 +23,7 @@ describe("createPaneFocusContextValue", () => {
isWorkspaceFocused: false,
isPaneFocused: true,
isInteractive: false,
focusPane: expect.any(Function),
});
});
});
+4
View File
@@ -17,19 +17,23 @@ export interface PaneFocusContextValue {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
isInteractive: boolean;
focusPane(): void;
}
const PaneContext = createContext<PaneContextValue | null>(null);
const PaneFocusContext = createContext<PaneFocusContextValue | null>(null);
const noopFocusPane = () => {};
export function createPaneFocusContextValue(input: {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
onFocusPane?: () => void;
}): PaneFocusContextValue {
return {
isWorkspaceFocused: input.isWorkspaceFocused,
isPaneFocused: input.isPaneFocused,
isInteractive: input.isWorkspaceFocused && input.isPaneFocused,
focusPane: input.onFocusPane ?? noopFocusPane,
};
}
@@ -1,4 +1,5 @@
import { agentPanelRegistration } from "@/panels/agent-panel";
import { browserPanelRegistration } from "@/panels/browser-panel";
import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry";
@@ -15,6 +16,7 @@ export function ensurePanelsRegistered(): void {
registerPanel(agentPanelRegistration);
registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration);
registerPanel(browserPanelRegistration);
registerPanel(filePanelRegistration);
panelsRegistered = true;
}