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
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MessageSquareCode } from "lucide-react-native";
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { AgentAttachment } from "@server/shared/messages";
import { useClearReviewDraft } from "@/review/store";
@@ -43,6 +44,16 @@ interface ComposerWorkspaceAttachmentBinding {
}
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
@@ -61,23 +72,32 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review";
return attachment?.kind === "review" || attachment?.kind === "browser_element";
}
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment => attachment.kind !== "review",
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "browser_element",
);
}
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
return isWorkspaceAttachment(attachment) ? attachment.attachment : null;
if (attachment.kind === "browser_element") {
return {
type: "text",
mimeType: "text/plain",
title: `Browser element · ${attachment.attachment.tag}`,
text: attachment.attachment.formatted,
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return (
<WorkspaceAttachmentPill
key={`workspace:${args.attachment.attachment.cwd}:${args.attachment.attachment.mode}`}
key={`workspace:${getAttachmentKey(args.attachment)}`}
{...args}
attachment={args.attachment}
/>
@@ -90,6 +110,9 @@ function useWorkspaceAttachmentBinding({
onOpenWorkspaceAttachment,
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
const clearReviewDraft = useClearReviewDraft();
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
const workspaceAttachmentKeys = useMemo(
() => workspaceAttachments.map(getAttachmentKey),
@@ -136,7 +159,7 @@ function useWorkspaceAttachmentBinding({
const clearSentAttachments = useCallback(
(attachments: readonly ComposerAttachment[]) => {
for (const attachment of attachments) {
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "review") {
clearReviewDraft({ key: attachment.reviewDraftKey });
}
}
@@ -148,17 +171,30 @@ function useWorkspaceAttachmentBinding({
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index];
if (isWorkspaceAttachment(selected)) {
if (selected.kind === "browser_element") {
const selectedKey = getAttachmentKey(selected);
const { attachmentsByScope } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
return true;
}
suppressWorkspaceAttachment(selected);
return true;
}
return false;
},
[suppressWorkspaceAttachment],
[setWorkspaceAttachments, suppressWorkspaceAttachment],
);
const openAttachment = useCallback(
({ attachment }: OpenWorkspaceAttachmentInput) => {
if (!isWorkspaceAttachment(attachment)) {
if (!isWorkspaceAttachment(attachment) || attachment.kind !== "review") {
return false;
}
onOpenWorkspaceAttachment?.(attachment);
@@ -216,10 +252,15 @@ function WorkspaceAttachmentPill({
onOpen,
onRemove,
}: WorkspaceAttachmentPillProps) {
const label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
let label: string;
if (attachment.kind === "browser_element") {
label = `Element · ${attachment.attachment.tag}`;
} else {
label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
}
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
@@ -231,13 +272,25 @@ function WorkspaceAttachmentPill({
testID="composer-review-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel="Open review attachment"
removeAccessibilityLabel="Remove review attachment"
openAccessibilityLabel={
attachment.kind === "browser_element"
? "Open browser element attachment"
: "Open review attachment"
}
removeAccessibilityLabel={
attachment.kind === "browser_element"
? "Remove browser element attachment"
: "Remove review attachment"
}
disabled={disabled}
>
<View style={styles.pillBody}>
<View style={styles.pillIcon}>
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
{attachment.kind === "browser_element" ? (
<ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
)}
</View>
<Text style={styles.pillText} numberOfLines={1}>
{label}
@@ -279,5 +332,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
})) as unknown as Record<string, object>;
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+36 -2
View File
@@ -17,10 +17,38 @@ export interface AttachmentMetadata {
createdAt: number;
}
export interface BrowserElementAttachment {
url: string;
selector: string;
tag: string;
text: string;
outerHTML: string;
computedStyles: Record<string, string>;
boundingRect: {
x: number;
y: number;
width: number;
height: number;
};
reactSource: {
fileName: string | null;
lineNumber: number | null;
columnNumber: number | null;
componentName: string | null;
} | null;
parentChain: string[];
children: string[];
formatted: string;
}
export type ComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem }
| {
kind: "browser_element";
attachment: BrowserElementAttachment;
}
| {
kind: "review";
attachment: Extract<AgentAttachment, { type: "review" }>;
@@ -28,9 +56,15 @@ export type ComposerAttachment =
commentCount: number;
};
export type UserComposerAttachment = Exclude<ComposerAttachment, { kind: "review" }>;
export type UserComposerAttachment = Exclude<
ComposerAttachment,
{ kind: "review" } | { kind: "browser_element" }
>;
export type WorkspaceComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
export type WorkspaceComposerAttachment = Extract<
ComposerAttachment,
{ kind: "review" } | { kind: "browser_element" }
>;
export type AttachmentDataSource =
| { kind: "bytes"; bytes: Uint8Array }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMemo } from "react";
interface BrowserPaneProps {
browserId: string;
serverId: string;
workspaceId: string;
cwd: string | null;
isInteractive?: boolean;
onFocusPane?: () => void;
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
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}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>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 { 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 { 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}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>
Open this workspace in Electron to use the built-in browser.
</Text>
<Text style={subtitleStyle}>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,
},
}));
@@ -226,6 +226,7 @@ vi.mock("lucide-react-native", () => {
Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"),
CircleDot: createIcon("CircleDot"),
MousePointer2: createIcon("MousePointer2"),
GitPullRequest: createIcon("GitPullRequest"),
MessageSquareCode: createIcon("MessageSquareCode"),
X: createIcon("X"),
@@ -576,6 +577,7 @@ let workspaceBindingRenderCount = 0;
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
type BrowserElementComposerAttachment = Extract<ComposerAttachment, { kind: "browser_element" }>;
function reviewAttachment(body: string): ReviewAttachment {
return {
@@ -621,6 +623,30 @@ function reviewComposerAttachment(body: string): ReviewComposerAttachment {
};
}
function browserElementComposerAttachment(): BrowserElementComposerAttachment {
return {
kind: "browser_element",
attachment: {
url: "https://example.com/page",
selector: "button.primary",
tag: "button",
text: "Save",
outerHTML: '<button class="primary">Save</button>',
computedStyles: { display: "flex" },
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
reactSource: {
fileName: "src/save-button.tsx",
lineNumber: 12,
columnNumber: 3,
componentName: "SaveButton",
},
parentChain: ["form.settings"],
children: [],
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
},
};
}
function cloneReviewComposerAttachment(
attachment: ReviewComposerAttachment,
): ReviewComposerAttachment {
@@ -1045,6 +1071,21 @@ describe("Composer attachments", () => {
});
});
it("serializes browser element workspace attachments as generic text attachments", async () => {
const browserElement = browserElementComposerAttachment();
expect(splitComposerAttachmentsForSubmit([browserElement])).toEqual({
images: [],
attachments: [
{
type: "text",
mimeType: "text/plain",
title: "Browser element · button",
text: browserElement.attachment.formatted,
},
],
});
});
it("does not enqueue redundant binding renders for equivalent workspace attachments", async () => {
const review = reviewComposerAttachment("Stable workspace review.");
+2
View File
@@ -426,6 +426,8 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
return `PR #${attachment.number}`;
case "github_issue":
return `Issue #${attachment.number}`;
case "text":
return attachment.title ?? "Text attachment";
}
}
@@ -91,6 +91,8 @@ interface SplitContainerProps {
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
@@ -158,6 +160,7 @@ interface MountedTabSlotProps {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
paneId: string;
onFocusPane: (paneId: string) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
@@ -170,6 +173,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
isWorkspaceFocused,
isPaneFocused,
paneId,
onFocusPane,
buildPaneContentModel,
}: MountedTabSlotProps) {
const content = useMemo(
@@ -185,6 +189,9 @@ const MountedTabSlot = memo(function MountedTabSlot({
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
[isVisible],
);
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
return (
<View style={wrapperStyle}>
@@ -192,6 +199,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
);
@@ -339,6 +347,8 @@ export function SplitContainer({
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -557,6 +567,8 @@ export function SplitContainer({
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -694,6 +706,8 @@ function SplitNodeView({
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -744,6 +758,8 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -787,6 +803,8 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -836,6 +854,8 @@ function SplitPaneView({
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel,
onFocusPane,
onSplitPane: _onSplitPane,
@@ -977,6 +997,8 @@ function SplitPaneView({
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
@@ -1004,6 +1026,7 @@ function SplitPaneView({
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
+5
View File
@@ -69,6 +69,11 @@ export interface DesktopEventsBridge {
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
}
export interface DesktopBrowserShortcutEvent {
browserId?: string;
action: "focus-url";
}
export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
@@ -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;
}
@@ -24,6 +24,7 @@ import {
Copy,
RotateCw,
Rows2,
Globe,
SquarePen,
SquareTerminal,
X,
@@ -72,6 +73,7 @@ const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
const ThemedCopyX = withUnistyles(CopyX);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2);
@@ -153,6 +155,8 @@ interface WorkspaceDesktopTabsRowProps {
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
disableCreateTerminal?: boolean;
isWaitingOnTerminalReadiness?: boolean;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
@@ -468,6 +472,8 @@ export function WorkspaceDesktopTabsRow({
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab = false,
disableCreateTerminal = false,
isWaitingOnTerminalReadiness = false,
onReorderTabs,
@@ -549,6 +555,10 @@ export function WorkspaceDesktopTabsRow({
onCreateTerminalTab({ paneId });
}, [onCreateTerminalTab, paneId]);
const handleCreateBrowser = useCallback(() => {
onCreateBrowserTab({ paneId });
}, [onCreateBrowserTab, paneId]);
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
const newTerminalActionButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
@@ -707,6 +717,24 @@ export function WorkspaceDesktopTabsRow({
</View>
</TooltipContent>
</Tooltip>
{showCreateBrowserTab ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-browser"
onPress={handleCreateBrowser}
accessibilityRole="button"
accessibilityLabel="New browser tab"
style={newTabActionButtonStyle}
>
<ThemedGlobe size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New browser tab</Text>
</View>
</TooltipContent>
</Tooltip>
) : null}
{showPaneSplitActions ? (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -342,6 +342,9 @@ export function WorkspaceDraftAgentTab({
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
if (attachment.kind !== "review") {
return;
}
const checkout = {
serverId,
cwd: attachment.attachment.cwd,
@@ -110,11 +110,13 @@ describe("WorkspacePaneContent", () => {
isWorkspaceFocused: true,
isPaneFocused: false,
isInteractive: false,
focusPane: expect.any(Function),
});
expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
focusPane: expect.any(Function),
});
});
});
@@ -58,12 +58,14 @@ export interface WorkspacePaneContentProps {
content: WorkspacePaneContentModel;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
onFocusPane?: () => void;
}
export function WorkspacePaneContent({
content,
isWorkspaceFocused,
isPaneFocused,
onFocusPane,
}: WorkspacePaneContentProps) {
const { Component, key, paneContextValue } = content;
const paneFocusValue = useMemo(
@@ -71,8 +73,9 @@ export function WorkspacePaneContent({
createPaneFocusContextValue({
isWorkspaceFocused,
isPaneFocused,
onFocusPane,
}),
[isPaneFocused, isWorkspaceFocused],
[isPaneFocused, isWorkspaceFocused, onFocusPane],
);
return (
@@ -13,6 +13,7 @@ import {
Copy,
Ellipsis,
EllipsisVertical,
Globe,
PanelRight,
RotateCw,
Settings,
@@ -80,6 +81,7 @@ import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { createWorkspaceBrowser } from "@/stores/browser-store";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import {
@@ -128,7 +130,7 @@ import {
import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
const TERMINALS_QUERY_STALE_TIME = 5_000;
@@ -149,6 +151,7 @@ const ThemedCopyX = withUnistyles(CopyX);
const ThemedX = withUnistyles(X);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight);
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
@@ -160,6 +163,7 @@ const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
@@ -199,6 +203,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "terminal") {
return "Terminal";
}
if (tab.target.kind === "browser") {
return "Browser";
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
@@ -218,6 +225,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "terminal") {
return "Terminal";
}
if (tab.target.kind === "browser") {
return "Browser";
}
return tab.target.path;
}
@@ -723,14 +733,17 @@ interface WorkspaceHeaderMenuProps {
normalizedWorkspaceId: string;
currentBranchName: string | null;
showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
@@ -754,14 +767,17 @@ function WorkspaceHeaderMenu({
normalizedWorkspaceId,
currentBranchName,
showWorkspaceSetup,
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateBrowser,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
@@ -799,6 +815,15 @@ function WorkspaceHeaderMenu({
>
New terminal
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
leading={menuNewBrowserIcon}
onSelect={onCreateBrowser}
>
New browser tab
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={menuCopyIcon}
@@ -843,14 +868,17 @@ interface WorkspaceHeaderTitleBarProps {
normalizedServerId: string;
normalizedWorkspaceId: string;
showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
@@ -866,14 +894,17 @@ function WorkspaceHeaderTitleBar({
normalizedServerId,
normalizedWorkspaceId,
showWorkspaceSetup,
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateBrowser,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
@@ -908,14 +939,17 @@ function WorkspaceHeaderTitleBar({
normalizedWorkspaceId={normalizedWorkspaceId}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={menuNewBrowserIcon}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminal={onCreateTerminal}
onCreateBrowser={onCreateBrowser}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
@@ -1928,6 +1962,20 @@ function WorkspaceScreenContent({
toast.show("Preparing workspace, opening terminal when ready...");
});
const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => {
if (!persistenceKey || !getIsElectron()) {
return;
}
if (input?.paneId) {
focusWorkspacePane(persistenceKey, input.paneId);
}
const { browserId } = createWorkspaceBrowser();
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleSelectSwitcherTab = useCallback(
(key: string) => {
navigateToTabId(key);
@@ -2823,6 +2871,7 @@ function WorkspaceScreenContent({
() => createTerminalMutation.isPending || pendingTerminalCreateInput !== null,
[createTerminalMutation.isPending, pendingTerminalCreateInput],
);
const showCreateBrowserTab = getIsElectron();
const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]);
const desktopFocusModeEnabled = useMemo(
() => isFocusModeEnabled && !isMobile,
@@ -2855,6 +2904,8 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabsInPane}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane}
onSplitPane={handleSplitPane}
@@ -2887,6 +2938,8 @@ function WorkspaceScreenContent({
handleCloseOtherTabsInPane,
handleCreateDraftTab,
handleCreateTerminal,
handleCreateBrowserTab,
showCreateBrowserTab,
buildDesktopPaneContentModel,
handleFocusPane,
handleSplitPane,
@@ -2932,14 +2985,17 @@ function WorkspaceScreenContent({
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
@@ -2989,6 +3045,8 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
@@ -81,6 +81,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return `workspace-draft-close-${tab.target.draftId}`;
}
if (tab.target.kind === "browser") {
return `workspace-browser-close-${tab.target.browserId}`;
}
if (tab.target.kind === "setup") {
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
}
@@ -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 };
@@ -18,6 +18,10 @@ export function normalizeWorkspaceTabTarget(
const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null;
}
if (value.kind === "browser") {
const browserId = trimNonEmpty(value.browserId);
return browserId ? { kind: "browser", browserId } : null;
}
if (value.kind === "file") {
const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
@@ -45,6 +49,9 @@ export function workspaceTabTargetsEqual(
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "browser" && right.kind === "browser") {
return left.browserId === right.browserId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
@@ -64,6 +71,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
if (target.kind === "browser") {
return `browser_${target.browserId}`;
}
if (target.kind === "setup") {
return `setup_${target.workspaceId}`;
}