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
+8
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 use-audio-recorder.native.ts ← uses expo-audio
``` ```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically. 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 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. - **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. - **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.
+1 -1
View File
@@ -63,7 +63,7 @@
"web": "npm run web --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --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 --", "db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js", "cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version": "npm run version:sync-internal && npm run release:prepare && git add -A",
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MessageSquareCode } from "lucide-react-native"; import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type { import type {
ComposerAttachment, ComposerAttachment,
UserComposerAttachment, UserComposerAttachment,
WorkspaceComposerAttachment, WorkspaceComposerAttachment,
} from "@/attachments/types"; } from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { AgentAttachment } from "@server/shared/messages"; import type { AgentAttachment } from "@server/shared/messages";
import { useClearReviewDraft } from "@/review/store"; import { useClearReviewDraft } from "@/review/store";
@@ -43,6 +44,16 @@ interface ComposerWorkspaceAttachmentBinding {
} }
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string { 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({ return JSON.stringify({
type: "review", type: "review",
cwd: attachment.attachment.cwd, cwd: attachment.attachment.cwd,
@@ -61,23 +72,32 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
function isWorkspaceAttachment( function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined, attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment { ): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review"; return attachment?.kind === "review" || attachment?.kind === "browser_element";
} }
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] { function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
return attachments.filter( 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 { 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 { function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return ( return (
<WorkspaceAttachmentPill <WorkspaceAttachmentPill
key={`workspace:${args.attachment.attachment.cwd}:${args.attachment.attachment.mode}`} key={`workspace:${getAttachmentKey(args.attachment)}`}
{...args} {...args}
attachment={args.attachment} attachment={args.attachment}
/> />
@@ -90,6 +110,9 @@ function useWorkspaceAttachmentBinding({
onOpenWorkspaceAttachment, onOpenWorkspaceAttachment,
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding { }: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
const clearReviewDraft = useClearReviewDraft(); const clearReviewDraft = useClearReviewDraft();
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]); const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
const workspaceAttachmentKeys = useMemo( const workspaceAttachmentKeys = useMemo(
() => workspaceAttachments.map(getAttachmentKey), () => workspaceAttachments.map(getAttachmentKey),
@@ -136,7 +159,7 @@ function useWorkspaceAttachmentBinding({
const clearSentAttachments = useCallback( const clearSentAttachments = useCallback(
(attachments: readonly ComposerAttachment[]) => { (attachments: readonly ComposerAttachment[]) => {
for (const attachment of attachments) { for (const attachment of attachments) {
if (isWorkspaceAttachment(attachment)) { if (attachment.kind === "review") {
clearReviewDraft({ key: attachment.reviewDraftKey }); clearReviewDraft({ key: attachment.reviewDraftKey });
} }
} }
@@ -148,17 +171,30 @@ function useWorkspaceAttachmentBinding({
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => { ({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index]; const selected = current[index];
if (isWorkspaceAttachment(selected)) { 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); suppressWorkspaceAttachment(selected);
return true; return true;
} }
return false; return false;
}, },
[suppressWorkspaceAttachment], [setWorkspaceAttachments, suppressWorkspaceAttachment],
); );
const openAttachment = useCallback( const openAttachment = useCallback(
({ attachment }: OpenWorkspaceAttachmentInput) => { ({ attachment }: OpenWorkspaceAttachmentInput) => {
if (!isWorkspaceAttachment(attachment)) { if (!isWorkspaceAttachment(attachment) || attachment.kind !== "review") {
return false; return false;
} }
onOpenWorkspaceAttachment?.(attachment); onOpenWorkspaceAttachment?.(attachment);
@@ -216,10 +252,15 @@ function WorkspaceAttachmentPill({
onOpen, onOpen,
onRemove, onRemove,
}: WorkspaceAttachmentPillProps) { }: WorkspaceAttachmentPillProps) {
const label = let label: string;
attachment.commentCount === 1 if (attachment.kind === "browser_element") {
? "Review · 1 comment" label = `Element · ${attachment.attachment.tag}`;
: `Review · ${attachment.commentCount} comments`; } else {
label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
}
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
onOpen(attachment); onOpen(attachment);
}, [onOpen, attachment]); }, [onOpen, attachment]);
@@ -231,13 +272,25 @@ function WorkspaceAttachmentPill({
testID="composer-review-attachment-pill" testID="composer-review-attachment-pill"
onOpen={handleOpen} onOpen={handleOpen}
onRemove={handleRemove} onRemove={handleRemove}
openAccessibilityLabel="Open review attachment" openAccessibilityLabel={
removeAccessibilityLabel="Remove review attachment" 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} disabled={disabled}
> >
<View style={styles.pillBody}> <View style={styles.pillBody}>
<View style={styles.pillIcon}> <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> </View>
<Text style={styles.pillText} numberOfLines={1}> <Text style={styles.pillText} numberOfLines={1}>
{label} {label}
@@ -279,5 +332,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
}, },
})) as unknown as Record<string, object>; })) as unknown as Record<string, object>;
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode); const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+36 -2
View File
@@ -17,10 +17,38 @@ export interface AttachmentMetadata {
createdAt: number; 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 = export type ComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata } | { kind: "image"; metadata: AttachmentMetadata }
| { kind: "github_issue"; item: GitHubSearchItem } | { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem } | { kind: "github_pr"; item: GitHubSearchItem }
| {
kind: "browser_element";
attachment: BrowserElementAttachment;
}
| { | {
kind: "review"; kind: "review";
attachment: Extract<AgentAttachment, { type: "review" }>; attachment: Extract<AgentAttachment, { type: "review" }>;
@@ -28,9 +56,15 @@ export type ComposerAttachment =
commentCount: number; 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 = export type AttachmentDataSource =
| { kind: "bytes"; bytes: Uint8Array } | { 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"), Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"), AudioLines: createIcon("AudioLines"),
CircleDot: createIcon("CircleDot"), CircleDot: createIcon("CircleDot"),
MousePointer2: createIcon("MousePointer2"),
GitPullRequest: createIcon("GitPullRequest"), GitPullRequest: createIcon("GitPullRequest"),
MessageSquareCode: createIcon("MessageSquareCode"), MessageSquareCode: createIcon("MessageSquareCode"),
X: createIcon("X"), X: createIcon("X"),
@@ -576,6 +577,7 @@ let workspaceBindingRenderCount = 0;
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>; type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>; type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
type BrowserElementComposerAttachment = Extract<ComposerAttachment, { kind: "browser_element" }>;
function reviewAttachment(body: string): ReviewAttachment { function reviewAttachment(body: string): ReviewAttachment {
return { 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( function cloneReviewComposerAttachment(
attachment: ReviewComposerAttachment, attachment: ReviewComposerAttachment,
): 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 () => { it("does not enqueue redundant binding renders for equivalent workspace attachments", async () => {
const review = reviewComposerAttachment("Stable workspace review."); const review = reviewComposerAttachment("Stable workspace review.");
+2
View File
@@ -426,6 +426,8 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
return `PR #${attachment.number}`; return `PR #${attachment.number}`;
case "github_issue": case "github_issue":
return `Issue #${attachment.number}`; 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; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void; onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
@@ -158,6 +160,7 @@ interface MountedTabSlotProps {
isWorkspaceFocused: boolean; isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
paneId: string; paneId: string;
onFocusPane: (paneId: string) => void;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
@@ -170,6 +173,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
isWorkspaceFocused, isWorkspaceFocused,
isPaneFocused, isPaneFocused,
paneId, paneId,
onFocusPane,
buildPaneContentModel, buildPaneContentModel,
}: MountedTabSlotProps) { }: MountedTabSlotProps) {
const content = useMemo( const content = useMemo(
@@ -185,6 +189,9 @@ const MountedTabSlot = memo(function MountedTabSlot({
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }), () => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
[isVisible], [isVisible],
); );
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
return ( return (
<View style={wrapperStyle}> <View style={wrapperStyle}>
@@ -192,6 +199,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
content={content} content={content}
isWorkspaceFocused={isWorkspaceFocused} isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused} isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/> />
</View> </View>
); );
@@ -339,6 +347,8 @@ export function SplitContainer({
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminalTab, onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
@@ -557,6 +567,8 @@ export function SplitContainer({
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab} onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
@@ -694,6 +706,8 @@ function SplitNodeView({
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminalTab, onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
@@ -744,6 +758,8 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab} onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
@@ -787,6 +803,8 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab} onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
@@ -836,6 +854,8 @@ function SplitPaneView({
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminalTab, onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane: _onSplitPane, onSplitPane: _onSplitPane,
@@ -977,6 +997,8 @@ function SplitPaneView({
onCloseOtherTabs={handleCloseOtherTabs} onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab} onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs} onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight} onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown} onSplitDown={handleSplitDown}
@@ -1004,6 +1026,7 @@ function SplitPaneView({
isWorkspaceFocused={isWorkspaceFocused} isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId} isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id} paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
/> />
); );
+5
View File
@@ -69,6 +69,11 @@ export interface DesktopEventsBridge {
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void); on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
} }
export interface DesktopBrowserShortcutEvent {
browserId?: string;
action: "focus-url";
}
export interface DesktopInvokeBridge { export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>; invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
} }
@@ -314,6 +314,7 @@ async function renderAgentPanel(
isWorkspaceFocused: true, isWorkspaceFocused: true,
isPaneFocused: false, isPaneFocused: false,
isInteractive: false, isInteractive: false,
focusPane: vi.fn(),
}, },
) { ) {
const AgentPanel = agentPanelRegistration.component; const AgentPanel = agentPanelRegistration.component;
@@ -462,6 +463,7 @@ describe("AgentPanel render isolation", () => {
isWorkspaceFocused: false, isWorkspaceFocused: false,
isPaneFocused: true, isPaneFocused: true,
isInteractive: false, isInteractive: false,
focusPane: vi.fn(),
}); });
expect(latestComposerIsPaneFocused.current).toBe(false); expect(latestComposerIsPaneFocused.current).toBe(false);
@@ -545,6 +547,7 @@ describe("AgentPanel render isolation", () => {
isWorkspaceFocused: true, isWorkspaceFocused: true,
isPaneFocused: true, isPaneFocused: true,
isInteractive: true, isInteractive: true,
focusPane: vi.fn(),
}); });
const timeoutAt = Date.now() + 300; const timeoutAt = Date.now() + 300;
+3
View File
@@ -1264,6 +1264,9 @@ function ActiveAgentComposer({
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback( const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => { (attachment: WorkspaceComposerAttachment) => {
if (attachment.kind !== "review") {
return;
}
const checkout = { const checkout = {
serverId, serverId,
cwd: attachment.attachment.cwd, 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, isWorkspaceFocused: true,
isPaneFocused: true, isPaneFocused: true,
isInteractive: true, isInteractive: true,
focusPane: expect.any(Function),
}); });
expect( expect(
createPaneFocusContextValue({ createPaneFocusContextValue({
@@ -22,6 +23,7 @@ describe("createPaneFocusContextValue", () => {
isWorkspaceFocused: false, isWorkspaceFocused: false,
isPaneFocused: true, isPaneFocused: true,
isInteractive: false, isInteractive: false,
focusPane: expect.any(Function),
}); });
}); });
}); });
+4
View File
@@ -17,19 +17,23 @@ export interface PaneFocusContextValue {
isWorkspaceFocused: boolean; isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
isInteractive: boolean; isInteractive: boolean;
focusPane(): void;
} }
const PaneContext = createContext<PaneContextValue | null>(null); const PaneContext = createContext<PaneContextValue | null>(null);
const PaneFocusContext = createContext<PaneFocusContextValue | null>(null); const PaneFocusContext = createContext<PaneFocusContextValue | null>(null);
const noopFocusPane = () => {};
export function createPaneFocusContextValue(input: { export function createPaneFocusContextValue(input: {
isWorkspaceFocused: boolean; isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
onFocusPane?: () => void;
}): PaneFocusContextValue { }): PaneFocusContextValue {
return { return {
isWorkspaceFocused: input.isWorkspaceFocused, isWorkspaceFocused: input.isWorkspaceFocused,
isPaneFocused: input.isPaneFocused, isPaneFocused: input.isPaneFocused,
isInteractive: input.isWorkspaceFocused && input.isPaneFocused, isInteractive: input.isWorkspaceFocused && input.isPaneFocused,
focusPane: input.onFocusPane ?? noopFocusPane,
}; };
} }
@@ -1,4 +1,5 @@
import { agentPanelRegistration } from "@/panels/agent-panel"; import { agentPanelRegistration } from "@/panels/agent-panel";
import { browserPanelRegistration } from "@/panels/browser-panel";
import { draftPanelRegistration } from "@/panels/draft-panel"; import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel"; import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry"; import { registerPanel } from "@/panels/panel-registry";
@@ -15,6 +16,7 @@ export function ensurePanelsRegistered(): void {
registerPanel(agentPanelRegistration); registerPanel(agentPanelRegistration);
registerPanel(setupPanelRegistration); registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration); registerPanel(terminalPanelRegistration);
registerPanel(browserPanelRegistration);
registerPanel(filePanelRegistration); registerPanel(filePanelRegistration);
panelsRegistered = true; panelsRegistered = true;
} }
@@ -24,6 +24,7 @@ import {
Copy, Copy,
RotateCw, RotateCw,
Rows2, Rows2,
Globe,
SquarePen, SquarePen,
SquareTerminal, SquareTerminal,
X, X,
@@ -72,6 +73,7 @@ const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
const ThemedCopyX = withUnistyles(CopyX); const ThemedCopyX = withUnistyles(CopyX);
const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2); const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2); const ThemedRows2 = withUnistyles(Rows2);
@@ -153,6 +155,8 @@ interface WorkspaceDesktopTabsRowProps {
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void; onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
disableCreateTerminal?: boolean; disableCreateTerminal?: boolean;
isWaitingOnTerminalReadiness?: boolean; isWaitingOnTerminalReadiness?: boolean;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
@@ -468,6 +472,8 @@ export function WorkspaceDesktopTabsRow({
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminalTab, onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab = false,
disableCreateTerminal = false, disableCreateTerminal = false,
isWaitingOnTerminalReadiness = false, isWaitingOnTerminalReadiness = false,
onReorderTabs, onReorderTabs,
@@ -549,6 +555,10 @@ export function WorkspaceDesktopTabsRow({
onCreateTerminalTab({ paneId }); onCreateTerminalTab({ paneId });
}, [onCreateTerminalTab, paneId]); }, [onCreateTerminalTab, paneId]);
const handleCreateBrowser = useCallback(() => {
onCreateBrowserTab({ paneId });
}, [onCreateBrowserTab, paneId]);
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
const newTerminalActionButtonStyle = useCallback( const newTerminalActionButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [ ({ hovered, pressed }: PressableStateCallbackType) => [
@@ -707,6 +717,24 @@ export function WorkspaceDesktopTabsRow({
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </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 ? ( {showPaneSplitActions ? (
<> <>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -342,6 +342,9 @@ export function WorkspaceDraftAgentTab({
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback( const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => { (attachment: WorkspaceComposerAttachment) => {
if (attachment.kind !== "review") {
return;
}
const checkout = { const checkout = {
serverId, serverId,
cwd: attachment.attachment.cwd, cwd: attachment.attachment.cwd,
@@ -110,11 +110,13 @@ describe("WorkspacePaneContent", () => {
isWorkspaceFocused: true, isWorkspaceFocused: true,
isPaneFocused: false, isPaneFocused: false,
isInteractive: false, isInteractive: false,
focusPane: expect.any(Function),
}); });
expect(snapshots[1]?.focus).toEqual({ expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: true, isWorkspaceFocused: true,
isPaneFocused: true, isPaneFocused: true,
isInteractive: true, isInteractive: true,
focusPane: expect.any(Function),
}); });
}); });
}); });
@@ -58,12 +58,14 @@ export interface WorkspacePaneContentProps {
content: WorkspacePaneContentModel; content: WorkspacePaneContentModel;
isWorkspaceFocused: boolean; isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
onFocusPane?: () => void;
} }
export function WorkspacePaneContent({ export function WorkspacePaneContent({
content, content,
isWorkspaceFocused, isWorkspaceFocused,
isPaneFocused, isPaneFocused,
onFocusPane,
}: WorkspacePaneContentProps) { }: WorkspacePaneContentProps) {
const { Component, key, paneContextValue } = content; const { Component, key, paneContextValue } = content;
const paneFocusValue = useMemo( const paneFocusValue = useMemo(
@@ -71,8 +73,9 @@ export function WorkspacePaneContent({
createPaneFocusContextValue({ createPaneFocusContextValue({
isWorkspaceFocused, isWorkspaceFocused,
isPaneFocused, isPaneFocused,
onFocusPane,
}), }),
[isPaneFocused, isWorkspaceFocused], [isPaneFocused, isWorkspaceFocused, onFocusPane],
); );
return ( return (
@@ -13,6 +13,7 @@ import {
Copy, Copy,
Ellipsis, Ellipsis,
EllipsisVertical, EllipsisVertical,
Globe,
PanelRight, PanelRight,
RotateCw, RotateCw,
Settings, Settings,
@@ -80,6 +81,7 @@ import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { createWorkspaceBrowser } from "@/stores/browser-store";
import { buildProviderCommand } from "@/utils/provider-command-templates"; import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { import {
@@ -128,7 +130,7 @@ import {
import { findAdjacentPane } from "@/utils/split-navigation"; import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; 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"; import { useContainerWidthBelow } from "@/hooks/use-container-width";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
@@ -149,6 +151,7 @@ const ThemedCopyX = withUnistyles(CopyX);
const ThemedX = withUnistyles(X); const ThemedX = withUnistyles(X);
const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSettings = withUnistyles(Settings); const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight); const ThemedPanelRight = withUnistyles(PanelRight);
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon); 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_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal 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_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings 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") { if (tab.target.kind === "terminal") {
return "Terminal"; return "Terminal";
} }
if (tab.target.kind === "browser") {
return "Browser";
}
if (tab.target.kind === "file") { if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
} }
@@ -218,6 +225,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "terminal") { if (tab.target.kind === "terminal") {
return "Terminal"; return "Terminal";
} }
if (tab.target.kind === "browser") {
return "Browser";
}
return tab.target.path; return tab.target.path;
} }
@@ -723,14 +733,17 @@ interface WorkspaceHeaderMenuProps {
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
currentBranchName: string | null; currentBranchName: string | null;
showWorkspaceSetup: boolean; showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean;
isMobile: boolean; isMobile: boolean;
createTerminalDisabled: boolean; createTerminalDisabled: boolean;
menuNewAgentIcon: ReactElement; menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement; menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuCopyIcon: ReactElement; menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement; menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void; onCreateDraftTab: () => void;
onCreateTerminal: () => void; onCreateTerminal: () => void;
onCreateBrowser: () => void;
onCopyWorkspacePath: () => void; onCopyWorkspacePath: () => void;
onCopyBranchName: () => void; onCopyBranchName: () => void;
onOpenSetupTab: () => void; onOpenSetupTab: () => void;
@@ -754,14 +767,17 @@ function WorkspaceHeaderMenu({
normalizedWorkspaceId, normalizedWorkspaceId,
currentBranchName, currentBranchName,
showWorkspaceSetup, showWorkspaceSetup,
showCreateBrowserTab,
isMobile, isMobile,
createTerminalDisabled, createTerminalDisabled,
menuNewAgentIcon, menuNewAgentIcon,
menuNewTerminalIcon, menuNewTerminalIcon,
menuNewBrowserIcon,
menuCopyIcon, menuCopyIcon,
menuSettingsIcon, menuSettingsIcon,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminal, onCreateTerminal,
onCreateBrowser,
onCopyWorkspacePath, onCopyWorkspacePath,
onCopyBranchName, onCopyBranchName,
onOpenSetupTab, onOpenSetupTab,
@@ -799,6 +815,15 @@ function WorkspaceHeaderMenu({
> >
New terminal New terminal
</DropdownMenuItem> </DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
leading={menuNewBrowserIcon}
onSelect={onCreateBrowser}
>
New browser tab
</DropdownMenuItem>
) : null}
<DropdownMenuItem <DropdownMenuItem
testID="workspace-header-copy-path" testID="workspace-header-copy-path"
leading={menuCopyIcon} leading={menuCopyIcon}
@@ -843,14 +868,17 @@ interface WorkspaceHeaderTitleBarProps {
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
showWorkspaceSetup: boolean; showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean;
isMobile: boolean; isMobile: boolean;
createTerminalDisabled: boolean; createTerminalDisabled: boolean;
menuNewAgentIcon: ReactElement; menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement; menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuCopyIcon: ReactElement; menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement; menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void; onCreateDraftTab: () => void;
onCreateTerminal: () => void; onCreateTerminal: () => void;
onCreateBrowser: () => void;
onCopyWorkspacePath: () => void; onCopyWorkspacePath: () => void;
onCopyBranchName: () => void; onCopyBranchName: () => void;
onOpenSetupTab: () => void; onOpenSetupTab: () => void;
@@ -866,14 +894,17 @@ function WorkspaceHeaderTitleBar({
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
showWorkspaceSetup, showWorkspaceSetup,
showCreateBrowserTab,
isMobile, isMobile,
createTerminalDisabled, createTerminalDisabled,
menuNewAgentIcon, menuNewAgentIcon,
menuNewTerminalIcon, menuNewTerminalIcon,
menuNewBrowserIcon,
menuCopyIcon, menuCopyIcon,
menuSettingsIcon, menuSettingsIcon,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminal, onCreateTerminal,
onCreateBrowser,
onCopyWorkspacePath, onCopyWorkspacePath,
onCopyBranchName, onCopyBranchName,
onOpenSetupTab, onOpenSetupTab,
@@ -908,14 +939,17 @@ function WorkspaceHeaderTitleBar({
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
currentBranchName={currentBranchName} currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup} showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile} isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled} createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon} menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon} menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={menuNewBrowserIcon}
menuCopyIcon={menuCopyIcon} menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon} menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={onCreateDraftTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminal={onCreateTerminal} onCreateTerminal={onCreateTerminal}
onCreateBrowser={onCreateBrowser}
onCopyWorkspacePath={onCopyWorkspacePath} onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName} onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab} onOpenSetupTab={onOpenSetupTab}
@@ -1928,6 +1962,20 @@ function WorkspaceScreenContent({
toast.show("Preparing workspace, opening terminal when ready..."); 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( const handleSelectSwitcherTab = useCallback(
(key: string) => { (key: string) => {
navigateToTabId(key); navigateToTabId(key);
@@ -2823,6 +2871,7 @@ function WorkspaceScreenContent({
() => createTerminalMutation.isPending || pendingTerminalCreateInput !== null, () => createTerminalMutation.isPending || pendingTerminalCreateInput !== null,
[createTerminalMutation.isPending, pendingTerminalCreateInput], [createTerminalMutation.isPending, pendingTerminalCreateInput],
); );
const showCreateBrowserTab = getIsElectron();
const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]); const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]);
const desktopFocusModeEnabled = useMemo( const desktopFocusModeEnabled = useMemo(
() => isFocusModeEnabled && !isMobile, () => isFocusModeEnabled && !isMobile,
@@ -2855,6 +2904,8 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabsInPane} onCloseOtherTabs={handleCloseOtherTabsInPane}
onCreateDraftTab={handleCreateDraftTab} onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal} onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildDesktopPaneContentModel} buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane} onFocusPane={handleFocusPane}
onSplitPane={handleSplitPane} onSplitPane={handleSplitPane}
@@ -2887,6 +2938,8 @@ function WorkspaceScreenContent({
handleCloseOtherTabsInPane, handleCloseOtherTabsInPane,
handleCreateDraftTab, handleCreateDraftTab,
handleCreateTerminal, handleCreateTerminal,
handleCreateBrowserTab,
showCreateBrowserTab,
buildDesktopPaneContentModel, buildDesktopPaneContentModel,
handleFocusPane, handleFocusPane,
handleSplitPane, handleSplitPane,
@@ -2932,14 +2985,17 @@ function WorkspaceScreenContent({
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
showWorkspaceSetup={showWorkspaceSetup} showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile} isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled} createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon} menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon} menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuCopyIcon={menuCopyIcon} menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon} menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab} onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal} onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onCopyWorkspacePath={handleCopyWorkspacePath} onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName} onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab} onOpenSetupTab={handleOpenSetupTab}
@@ -2989,6 +3045,8 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabs} onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab} onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal} onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
disableCreateTerminal={createTerminalMutation.isPending} disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null} isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane} onReorderTabs={handleReorderTabsInFocusedPane}
@@ -81,6 +81,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
return `workspace-draft-close-${tab.target.draftId}`; return `workspace-draft-close-${tab.target.draftId}`;
} }
if (tab.target.kind === "browser") {
return `workspace-browser-close-${tab.target.browserId}`;
}
if (tab.target.kind === "setup") { if (tab.target.kind === "setup") {
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`; 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: "draft"; draftId: string }
| { kind: "agent"; agentId: string } | { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string } | { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| { kind: "file"; path: string } | { kind: "file"; path: string }
| { kind: "setup"; workspaceId: string }; | { kind: "setup"; workspaceId: string };
@@ -18,6 +18,10 @@ export function normalizeWorkspaceTabTarget(
const terminalId = trimNonEmpty(value.terminalId); const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null; 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") { if (value.kind === "file") {
const path = trimNonEmpty(value.path); const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
@@ -45,6 +49,9 @@ export function workspaceTabTargetsEqual(
if (left.kind === "terminal" && right.kind === "terminal") { if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId; return left.terminalId === right.terminalId;
} }
if (left.kind === "browser" && right.kind === "browser") {
return left.browserId === right.browserId;
}
if (left.kind === "file" && right.kind === "file") { if (left.kind === "file" && right.kind === "file") {
return left.path === right.path; return left.path === right.path;
} }
@@ -64,6 +71,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "terminal") { if (target.kind === "terminal") {
return `terminal_${target.terminalId}`; return `terminal_${target.terminalId}`;
} }
if (target.kind === "browser") {
return `browser_${target.browserId}`;
}
if (target.kind === "setup") { if (target.kind === "setup") {
return `setup_${target.workspaceId}`; return `setup_${target.workspaceId}`;
} }
+1 -1
View File
@@ -33,5 +33,5 @@ Write-Host @"
--kill-others ` --kill-others `
--names "metro,electron" ` --names "metro,electron" `
--prefix-colors "magenta,cyan" ` --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`"" "npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
+1 -1
View File
@@ -30,5 +30,5 @@ exec "$ROOT_DIR/node_modules/.bin/concurrently" \
--kill-others \ --kill-others \
--names "metro,electron" \ --names "metro,electron" \
--prefix-colors "magenta,cyan" \ --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'" "$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"
@@ -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;
}
+43 -3
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 { interface ShowContextMenuInput {
kind?: "terminal"; 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 { export function setupApplicationMenu(): void {
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
@@ -73,8 +101,20 @@ export function setupApplicationMenu(): void {
}), }),
}, },
{ type: "separator" }, { 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" }, { role: "toggleDevTools" },
{ type: "separator" }, { type: "separator" },
{ role: "togglefullscreen" }, { role: "togglefullscreen" },
+119
View File
@@ -33,6 +33,10 @@ import {
} from "./features/notifications.js"; } from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js"; import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js"; import { setupApplicationMenu } from "./features/menu.js";
import {
getPaseoBrowserIdForWebContents,
registerPaseoBrowserWebContents,
} from "./features/browser-webviews.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
import { import {
@@ -47,11 +51,60 @@ import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo"; 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 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_ENV = "PASEO_DESKTOP_SMOKE";
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop"; const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
app.setName("Paseo"); 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 // In dev mode, detect git worktrees and isolate each instance so multiple
// Electron windows can run side-by-side (separate userData = separate lock). // Electron windows can run side-by-side (separate userData = separate lock).
let devWorktreeName: string | null = null; let devWorktreeName: string | null = null;
@@ -206,6 +259,7 @@ async function createMainWindow(): Promise<void> {
preload: getPreloadPath(), preload: getPreloadPath(),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
webviewTag: true,
}, },
}); });
@@ -217,6 +271,71 @@ async function createMainWindow(): Promise<void> {
setupWindowResizeEvents(mainWindow); setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow); setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(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.once("ready-to-show", () => {
mainWindow.show(); mainWindow.show();
@@ -85,6 +85,17 @@ describe("prompt attachments", () => {
).toContain("GitHub Issue #55: Issue"); ).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", () => { it("returns undefined when firstAgentContext is empty", () => {
expect(buildAgentBranchNameSeed(undefined)).toBeUndefined(); expect(buildAgentBranchNameSeed(undefined)).toBeUndefined();
expect(buildAgentBranchNameSeed({})).toBeUndefined(); expect(buildAgentBranchNameSeed({})).toBeUndefined();
@@ -24,6 +24,9 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
} }
return lines.join("\n"); return lines.join("\n");
} }
case "text": {
return attachment.text;
}
case "review": { case "review": {
const lines = [`Paseo review attachment (${attachment.mode})`, `CWD: ${attachment.cwd}`]; const lines = [`Paseo review attachment (${attachment.mode})`, `CWD: ${attachment.cwd}`];
if (attachment.baseRef) { if (attachment.baseRef) {
+8
View File
@@ -727,6 +727,13 @@ export const GitHubIssueAttachmentSchema = z.object({
body: z.string().nullable().optional(), 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({ export const ReviewAttachmentContextLineSchema = z.object({
oldLineNumber: z.number().int().positive().nullable(), oldLineNumber: z.number().int().positive().nullable(),
newLineNumber: 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", [ export const AgentAttachmentSchema = z.discriminatedUnion("type", [
GitHubPrAttachmentSchema, GitHubPrAttachmentSchema,
GitHubIssueAttachmentSchema, GitHubIssueAttachmentSchema,
TextAttachmentSchema,
ReviewAttachmentSchema, ReviewAttachmentSchema,
]); ]);