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
parent 34b95e7f01
commit 906205f97a
35 changed files with 1990 additions and 25 deletions

View File

@@ -103,6 +103,14 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
```
components/
browser-pane.electron.tsx ← Electron <webview> implementation
browser-pane.web.tsx ← plain web fallback
browser-pane.tsx ← native fallback
```
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.

View File

@@ -63,7 +63,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && PASEO_WEB_PLATFORM=electron npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",

View File

@@ -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 });

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

View File

@@ -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,
},
}));

View File

@@ -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,
},
}));

View File

@@ -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.");

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";
}
}

View File

@@ -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}
/>
);

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>;
}

View File

@@ -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;

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,

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,
};

View File

@@ -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),
});
});
});

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,
};
}

View File

@@ -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;
}

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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),
});
});
});

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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)}`;
}

View File

@@ -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",
);
});
});

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);
}

View File

@@ -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 };

View File

@@ -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}`;
}

View File

@@ -33,5 +33,5 @@ Write-Host @"
--kill-others `
--names "metro,electron" `
--prefix-colors "magenta,cyan" `
"cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" `
"cd `"$AppDir`" && `$env:PASEO_WEB_PLATFORM = `"electron`"; npx expo start --port $($env:EXPO_PORT)" `
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""

View File

@@ -30,5 +30,5 @@ exec "$ROOT_DIR/node_modules/.bin/concurrently" \
--kill-others \
--names "metro,electron" \
--prefix-colors "magenta,cyan" \
"cd '$APP_DIR' && npx expo start --port $EXPO_PORT" \
"cd '$APP_DIR' && PASEO_WEB_PLATFORM=electron npx expo start --port $EXPO_PORT" \
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"

View File

@@ -0,0 +1,17 @@
import type { WebContents } from "electron";
const browserIdsByWebContentsId = new Map<number, string>();
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
browserIdsByWebContentsId.set(contents.id, browserId);
contents.once("destroyed", () => {
browserIdsByWebContentsId.delete(contents.id);
});
}
export function getPaseoBrowserIdForWebContents(contents: WebContents | null): string | null {
if (!contents || contents.isDestroyed()) {
return null;
}
return browserIdsByWebContentsId.get(contents.id) ?? null;
}

View File

@@ -1,4 +1,5 @@
import { app, Menu, BrowserWindow, ipcMain } from "electron";
import { app, Menu, BrowserWindow, ipcMain, webContents } from "electron";
import { getPaseoBrowserIdForWebContents } from "./browser-webviews.js";
interface ShowContextMenuInput {
kind?: "terminal";
@@ -14,6 +15,33 @@ function withBrowserWindow(
};
}
function getFocusedPaseoBrowserWebContents(): Electron.WebContents | null {
const focusedContents = webContents.getFocusedWebContents();
return getPaseoBrowserIdForWebContents(focusedContents) ? focusedContents : null;
}
function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCache?: boolean }) {
const browserContents = getFocusedPaseoBrowserWebContents();
if (browserContents) {
if (options?.ignoreCache) {
browserContents.reloadIgnoringCache();
return;
}
if (browserContents.isLoadingMainFrame()) {
browserContents.stop();
return;
}
browserContents.reload();
return;
}
if (options?.ignoreCache) {
win.webContents.reloadIgnoringCache();
return;
}
win.webContents.reload();
}
export function setupApplicationMenu(): void {
const isMac = process.platform === "darwin";
@@ -73,8 +101,20 @@ export function setupApplicationMenu(): void {
}),
},
{ type: "separator" },
{ role: "reload" },
{ role: "forceReload" },
{
label: "Reload",
accelerator: "CmdOrCtrl+R",
click: withBrowserWindow((win) => {
reloadFocusedContentsOrWindow(win);
}),
},
{
label: "Force Reload",
accelerator: "CmdOrCtrl+Shift+R",
click: withBrowserWindow((win) => {
reloadFocusedContentsOrWindow(win, { ignoreCache: true });
}),
},
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "togglefullscreen" },

View File

@@ -33,6 +33,10 @@ import {
} from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
getPaseoBrowserIdForWebContents,
registerPaseoBrowserWebContents,
} from "./features/browser-webviews.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
import {
@@ -47,11 +51,60 @@ import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
if (!value) {
return true;
}
try {
const parsed = new URL(value);
return (
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
);
} catch {
return false;
}
}
function preventUnsafeBrowserWebviewNavigation(
event: Electron.Event,
url: string | undefined,
): void {
if (!isAllowedBrowserWebviewUrl(url)) {
event.preventDefault();
}
}
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
app.setName("Paseo");
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
if (!partition?.startsWith(prefix)) {
return null;
}
const browserId = partition.slice(prefix.length).trim();
return browserId.length > 0 ? browserId : null;
}
const pendingBrowserWebviewIds: string[] = [];
function isBrowserRefreshInput(input: Electron.Input): boolean {
if (input.type !== "keyDown" || input.alt || input.shift) {
return false;
}
return (input.meta || input.control) && input.key.toLowerCase() === "r";
}
function isBrowserLocationInput(input: Electron.Input): boolean {
if (input.type !== "keyDown" || input.alt || input.shift) {
return false;
}
return (input.meta || input.control) && input.key.toLowerCase() === "l";
}
// In dev mode, detect git worktrees and isolate each instance so multiple
// Electron windows can run side-by-side (separate userData = separate lock).
let devWorktreeName: string | null = null;
@@ -206,6 +259,7 @@ async function createMainWindow(): Promise<void> {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
webviewTag: true,
},
});
@@ -217,6 +271,71 @@ async function createMainWindow(): Promise<void> {
setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
if (!isAllowedBrowserWebviewUrl(params.src)) {
event.preventDefault();
return;
}
const browserId = getBrowserIdFromWebviewPartition(params.partition);
if (!browserId) {
event.preventDefault();
return;
}
pendingBrowserWebviewIds.push(browserId);
webPreferences.nodeIntegration = false;
webPreferences.nodeIntegrationInSubFrames = false;
webPreferences.nodeIntegrationInWorker = false;
webPreferences.contextIsolation = true;
webPreferences.sandbox = true;
webPreferences.webSecurity = true;
webPreferences.webviewTag = false;
webPreferences.allowRunningInsecureContent = false;
delete webPreferences.preload;
delete params.preload;
delete (webPreferences as { preloadURL?: string }).preloadURL;
delete (params as { preloadURL?: string }).preloadURL;
});
mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
const browserId = pendingBrowserWebviewIds.shift() ?? null;
if (browserId) {
registerPaseoBrowserWebContents(contents, browserId);
}
contents.on("before-input-event", (event, input) => {
if (isBrowserRefreshInput(input)) {
event.preventDefault();
if (contents.isLoadingMainFrame()) {
contents.stop();
} else {
contents.reload();
}
return;
}
if (isBrowserLocationInput(input)) {
event.preventDefault();
const focusedBrowserId = getPaseoBrowserIdForWebContents(contents);
mainWindow.webContents.send(BROWSER_SHORTCUT_EVENT, {
action: "focus-url",
...(focusedBrowserId ? { browserId: focusedBrowserId } : {}),
});
}
});
contents.setWindowOpenHandler(({ url }) => {
if (!isAllowedBrowserWebviewUrl(url)) {
return { action: "deny" };
}
contents.loadURL(url).catch(() => undefined);
return { action: "deny" };
});
contents.on("will-navigate", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
contents.on("will-frame-navigate", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
contents.on("will-redirect", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
});
mainWindow.once("ready-to-show", () => {
mainWindow.show();

View File

@@ -85,6 +85,17 @@ describe("prompt attachments", () => {
).toContain("GitHub Issue #55: Issue");
});
it("renders text attachments as their client-provided prompt text", () => {
expect(
renderPromptAttachmentAsText({
type: "text",
mimeType: "text/plain",
title: "Browser element",
text: "<browser-element>button.primary</browser-element>",
}),
).toBe("<browser-element>button.primary</browser-element>");
});
it("returns undefined when firstAgentContext is empty", () => {
expect(buildAgentBranchNameSeed(undefined)).toBeUndefined();
expect(buildAgentBranchNameSeed({})).toBeUndefined();

View File

@@ -24,6 +24,9 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
}
return lines.join("\n");
}
case "text": {
return attachment.text;
}
case "review": {
const lines = [`Paseo review attachment (${attachment.mode})`, `CWD: ${attachment.cwd}`];
if (attachment.baseRef) {

View File

@@ -727,6 +727,13 @@ export const GitHubIssueAttachmentSchema = z.object({
body: z.string().nullable().optional(),
});
export const TextAttachmentSchema = z.object({
type: z.literal("text"),
mimeType: z.literal("text/plain"),
title: z.string().nullable().optional(),
text: z.string(),
});
export const ReviewAttachmentContextLineSchema = z.object({
oldLineNumber: z.number().int().positive().nullable(),
newLineNumber: z.number().int().positive().nullable(),
@@ -758,6 +765,7 @@ export const ReviewAttachmentSchema = z.object({
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
GitHubPrAttachmentSchema,
GitHubIssueAttachmentSchema,
TextAttachmentSchema,
ReviewAttachmentSchema,
]);