mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }
|
||||
|
||||
1080
packages/app/src/components/browser-pane.electron.tsx
Normal file
1080
packages/app/src/components/browser-pane.electron.tsx
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/app/src/components/browser-pane.tsx
Normal file
48
packages/app/src/components/browser-pane.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
51
packages/app/src/components/browser-pane.web.tsx
Normal file
51
packages/app/src/components/browser-pane.web.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -426,6 +426,8 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
return `PR #${attachment.number}`;
|
||||
case "github_issue":
|
||||
return `Issue #${attachment.number}`;
|
||||
case "text":
|
||||
return attachment.title ?? "Text attachment";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ interface SplitContainerProps {
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCreateDraftTab: (input: { paneId?: string }) => void;
|
||||
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
||||
onCreateBrowserTab: (input: { paneId?: string }) => void;
|
||||
showCreateBrowserTab?: boolean;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -158,6 +160,7 @@ interface MountedTabSlotProps {
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
paneId: string;
|
||||
onFocusPane: (paneId: string) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -170,6 +173,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
paneId,
|
||||
onFocusPane,
|
||||
buildPaneContentModel,
|
||||
}: MountedTabSlotProps) {
|
||||
const content = useMemo(
|
||||
@@ -185,6 +189,9 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
|
||||
[isVisible],
|
||||
);
|
||||
const handleFocusPane = useCallback(() => {
|
||||
onFocusPane(paneId);
|
||||
}, [onFocusPane, paneId]);
|
||||
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
@@ -192,6 +199,7 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -339,6 +347,8 @@ export function SplitContainer({
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -557,6 +567,8 @@ export function SplitContainer({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -694,6 +706,8 @@ function SplitNodeView({
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -744,6 +758,8 @@ function SplitNodeView({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -787,6 +803,8 @@ function SplitNodeView({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -836,6 +854,8 @@ function SplitPaneView({
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane: _onSplitPane,
|
||||
@@ -977,6 +997,8 @@ function SplitPaneView({
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
onSplitRight={handleSplitRight}
|
||||
onSplitDown={handleSplitDown}
|
||||
@@ -1004,6 +1026,7 @@ function SplitPaneView({
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||
paneId={pane.id}
|
||||
onFocusPane={stableOnFocusPane}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface DesktopEventsBridge {
|
||||
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export interface DesktopBrowserShortcutEvent {
|
||||
browserId?: string;
|
||||
action: "focus-url";
|
||||
}
|
||||
|
||||
export interface DesktopInvokeBridge {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@ async function renderAgentPanel(
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: false,
|
||||
isInteractive: false,
|
||||
focusPane: vi.fn(),
|
||||
},
|
||||
) {
|
||||
const AgentPanel = agentPanelRegistration.component;
|
||||
@@ -462,6 +463,7 @@ describe("AgentPanel render isolation", () => {
|
||||
isWorkspaceFocused: false,
|
||||
isPaneFocused: true,
|
||||
isInteractive: false,
|
||||
focusPane: vi.fn(),
|
||||
});
|
||||
|
||||
expect(latestComposerIsPaneFocused.current).toBe(false);
|
||||
@@ -545,6 +547,7 @@ describe("AgentPanel render isolation", () => {
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: true,
|
||||
isInteractive: true,
|
||||
focusPane: vi.fn(),
|
||||
});
|
||||
|
||||
const timeoutAt = Date.now() + 300;
|
||||
|
||||
@@ -1264,6 +1264,9 @@ function ActiveAgentComposer({
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
if (attachment.kind !== "review") {
|
||||
return;
|
||||
}
|
||||
const checkout = {
|
||||
serverId,
|
||||
cwd: attachment.attachment.cwd,
|
||||
|
||||
77
packages/app/src/panels/browser-panel.tsx
Normal file
77
packages/app/src/panels/browser-panel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from "react";
|
||||
import { Image } from "react-native";
|
||||
import { Globe } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { BrowserPane } from "@/components/browser-pane";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
|
||||
function getBrowserLabel(input: { title: string; url: string }): string {
|
||||
const title = input.title.trim();
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(input.url);
|
||||
return parsed.hostname || input.url;
|
||||
} catch {
|
||||
return input.url;
|
||||
}
|
||||
}
|
||||
|
||||
function createBrowserTabIcon(faviconUrl: string | null) {
|
||||
return function BrowserTabIcon({ size, color }: PanelIconProps) {
|
||||
const source = useMemo(() => (faviconUrl ? { uri: faviconUrl } : undefined), []);
|
||||
const imageStyle = useMemo(() => ({ width: size, height: size, borderRadius: 3 }), [size]);
|
||||
|
||||
if (faviconUrl) {
|
||||
return <Image accessibilityIgnoresInvertColors source={source} style={imageStyle} />;
|
||||
}
|
||||
|
||||
return <Globe size={size} color={color} />;
|
||||
};
|
||||
}
|
||||
|
||||
function useBrowserPanelDescriptor(target: {
|
||||
kind: "browser";
|
||||
browserId: string;
|
||||
}): PanelDescriptor {
|
||||
const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null);
|
||||
const url = browser?.url ?? "https://example.com";
|
||||
const icon = createBrowserTabIcon(browser?.faviconUrl ?? null);
|
||||
|
||||
return {
|
||||
label: getBrowserLabel({ title: browser?.title ?? "", url }),
|
||||
subtitle: url,
|
||||
titleState: "ready",
|
||||
icon,
|
||||
statusBucket: browser?.isLoading ? "running" : null,
|
||||
};
|
||||
}
|
||||
|
||||
function BrowserPanel() {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const { focusPane, isInteractive } = usePaneFocus();
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||
const cwd = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null;
|
||||
invariant(target.kind === "browser", "BrowserPanel requires browser target");
|
||||
return (
|
||||
<BrowserPane
|
||||
browserId={target.browserId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={cwd}
|
||||
isInteractive={isInteractive}
|
||||
onFocusPane={focusPane}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const browserPanelRegistration: PanelRegistration<"browser"> = {
|
||||
kind: "browser",
|
||||
component: BrowserPanel,
|
||||
useDescriptor: useBrowserPanelDescriptor,
|
||||
};
|
||||
@@ -12,6 +12,7 @@ describe("createPaneFocusContextValue", () => {
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: true,
|
||||
isInteractive: true,
|
||||
focusPane: expect.any(Function),
|
||||
});
|
||||
expect(
|
||||
createPaneFocusContextValue({
|
||||
@@ -22,6 +23,7 @@ describe("createPaneFocusContextValue", () => {
|
||||
isWorkspaceFocused: false,
|
||||
isPaneFocused: true,
|
||||
isInteractive: false,
|
||||
focusPane: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,19 +17,23 @@ export interface PaneFocusContextValue {
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
isInteractive: boolean;
|
||||
focusPane(): void;
|
||||
}
|
||||
|
||||
const PaneContext = createContext<PaneContextValue | null>(null);
|
||||
const PaneFocusContext = createContext<PaneFocusContextValue | null>(null);
|
||||
const noopFocusPane = () => {};
|
||||
|
||||
export function createPaneFocusContextValue(input: {
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
onFocusPane?: () => void;
|
||||
}): PaneFocusContextValue {
|
||||
return {
|
||||
isWorkspaceFocused: input.isWorkspaceFocused,
|
||||
isPaneFocused: input.isPaneFocused,
|
||||
isInteractive: input.isWorkspaceFocused && input.isPaneFocused,
|
||||
focusPane: input.onFocusPane ?? noopFocusPane,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { browserPanelRegistration } from "@/panels/browser-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
@@ -15,6 +16,7 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(agentPanelRegistration);
|
||||
registerPanel(setupPanelRegistration);
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(browserPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
panelsRegistered = true;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Copy,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
Globe,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
X,
|
||||
@@ -72,6 +73,7 @@ const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedColumns2 = withUnistyles(Columns2);
|
||||
const ThemedRows2 = withUnistyles(Rows2);
|
||||
|
||||
@@ -153,6 +155,8 @@ interface WorkspaceDesktopTabsRowProps {
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
onCreateDraftTab: (input: { paneId?: string }) => void;
|
||||
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
||||
onCreateBrowserTab: (input: { paneId?: string }) => void;
|
||||
showCreateBrowserTab?: boolean;
|
||||
disableCreateTerminal?: boolean;
|
||||
isWaitingOnTerminalReadiness?: boolean;
|
||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||
@@ -468,6 +472,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab = false,
|
||||
disableCreateTerminal = false,
|
||||
isWaitingOnTerminalReadiness = false,
|
||||
onReorderTabs,
|
||||
@@ -549,6 +555,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCreateTerminalTab({ paneId });
|
||||
}, [onCreateTerminalTab, paneId]);
|
||||
|
||||
const handleCreateBrowser = useCallback(() => {
|
||||
onCreateBrowserTab({ paneId });
|
||||
}, [onCreateBrowserTab, paneId]);
|
||||
|
||||
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
|
||||
const newTerminalActionButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType) => [
|
||||
@@ -707,6 +717,24 @@ export function WorkspaceDesktopTabsRow({
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{showCreateBrowserTab ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-browser"
|
||||
onPress={handleCreateBrowser}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New browser tab"
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
<ThemedGlobe size={14} uniProps={mutedColorMapping} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New browser tab</Text>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showPaneSplitActions ? (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
|
||||
@@ -342,6 +342,9 @@ export function WorkspaceDraftAgentTab({
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
if (attachment.kind !== "review") {
|
||||
return;
|
||||
}
|
||||
const checkout = {
|
||||
serverId,
|
||||
cwd: attachment.attachment.cwd,
|
||||
|
||||
@@ -110,11 +110,13 @@ describe("WorkspacePaneContent", () => {
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: false,
|
||||
isInteractive: false,
|
||||
focusPane: expect.any(Function),
|
||||
});
|
||||
expect(snapshots[1]?.focus).toEqual({
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: true,
|
||||
isInteractive: true,
|
||||
focusPane: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,12 +58,14 @@ export interface WorkspacePaneContentProps {
|
||||
content: WorkspacePaneContentModel;
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
onFocusPane?: () => void;
|
||||
}
|
||||
|
||||
export function WorkspacePaneContent({
|
||||
content,
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
onFocusPane,
|
||||
}: WorkspacePaneContentProps) {
|
||||
const { Component, key, paneContextValue } = content;
|
||||
const paneFocusValue = useMemo(
|
||||
@@ -71,8 +73,9 @@ export function WorkspacePaneContent({
|
||||
createPaneFocusContextValue({
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
onFocusPane,
|
||||
}),
|
||||
[isPaneFocused, isWorkspaceFocused],
|
||||
[isPaneFocused, isWorkspaceFocused, onFocusPane],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Copy,
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
Globe,
|
||||
PanelRight,
|
||||
RotateCw,
|
||||
Settings,
|
||||
@@ -80,6 +81,7 @@ import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -128,7 +130,7 @@ import {
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
@@ -149,6 +151,7 @@ const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedPanelRight = withUnistyles(PanelRight);
|
||||
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
|
||||
@@ -160,6 +163,7 @@ const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
|
||||
|
||||
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
|
||||
|
||||
@@ -199,6 +203,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "terminal") {
|
||||
return "Terminal";
|
||||
}
|
||||
if (tab.target.kind === "browser") {
|
||||
return "Browser";
|
||||
}
|
||||
if (tab.target.kind === "file") {
|
||||
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
||||
}
|
||||
@@ -218,6 +225,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "terminal") {
|
||||
return "Terminal";
|
||||
}
|
||||
if (tab.target.kind === "browser") {
|
||||
return "Browser";
|
||||
}
|
||||
return tab.target.path;
|
||||
}
|
||||
|
||||
@@ -723,14 +733,17 @@ interface WorkspaceHeaderMenuProps {
|
||||
normalizedWorkspaceId: string;
|
||||
currentBranchName: string | null;
|
||||
showWorkspaceSetup: boolean;
|
||||
showCreateBrowserTab: boolean;
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
menuCopyIcon: ReactElement;
|
||||
menuSettingsIcon: ReactElement;
|
||||
onCreateDraftTab: () => void;
|
||||
onCreateTerminal: () => void;
|
||||
onCreateBrowser: () => void;
|
||||
onCopyWorkspacePath: () => void;
|
||||
onCopyBranchName: () => void;
|
||||
onOpenSetupTab: () => void;
|
||||
@@ -754,14 +767,17 @@ function WorkspaceHeaderMenu({
|
||||
normalizedWorkspaceId,
|
||||
currentBranchName,
|
||||
showWorkspaceSetup,
|
||||
showCreateBrowserTab,
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
menuCopyIcon,
|
||||
menuSettingsIcon,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminal,
|
||||
onCreateBrowser,
|
||||
onCopyWorkspacePath,
|
||||
onCopyBranchName,
|
||||
onOpenSetupTab,
|
||||
@@ -799,6 +815,15 @@ function WorkspaceHeaderMenu({
|
||||
>
|
||||
New terminal
|
||||
</DropdownMenuItem>
|
||||
{showCreateBrowserTab ? (
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-browser"
|
||||
leading={menuNewBrowserIcon}
|
||||
onSelect={onCreateBrowser}
|
||||
>
|
||||
New browser tab
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={menuCopyIcon}
|
||||
@@ -843,14 +868,17 @@ interface WorkspaceHeaderTitleBarProps {
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
showWorkspaceSetup: boolean;
|
||||
showCreateBrowserTab: boolean;
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
menuCopyIcon: ReactElement;
|
||||
menuSettingsIcon: ReactElement;
|
||||
onCreateDraftTab: () => void;
|
||||
onCreateTerminal: () => void;
|
||||
onCreateBrowser: () => void;
|
||||
onCopyWorkspacePath: () => void;
|
||||
onCopyBranchName: () => void;
|
||||
onOpenSetupTab: () => void;
|
||||
@@ -866,14 +894,17 @@ function WorkspaceHeaderTitleBar({
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
showWorkspaceSetup,
|
||||
showCreateBrowserTab,
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
menuCopyIcon,
|
||||
menuSettingsIcon,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminal,
|
||||
onCreateBrowser,
|
||||
onCopyWorkspacePath,
|
||||
onCopyBranchName,
|
||||
onOpenSetupTab,
|
||||
@@ -908,14 +939,17 @@ function WorkspaceHeaderTitleBar({
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={menuNewBrowserIcon}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminal={onCreateTerminal}
|
||||
onCreateBrowser={onCreateBrowser}
|
||||
onCopyWorkspacePath={onCopyWorkspacePath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onOpenSetupTab={onOpenSetupTab}
|
||||
@@ -1928,6 +1962,20 @@ function WorkspaceScreenContent({
|
||||
toast.show("Preparing workspace, opening terminal when ready...");
|
||||
});
|
||||
|
||||
const handleCreateBrowserTab = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
if (!persistenceKey || !getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
if (input?.paneId) {
|
||||
focusWorkspacePane(persistenceKey, input.paneId);
|
||||
}
|
||||
const { browserId } = createWorkspaceBrowser();
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
|
||||
},
|
||||
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
const handleSelectSwitcherTab = useCallback(
|
||||
(key: string) => {
|
||||
navigateToTabId(key);
|
||||
@@ -2823,6 +2871,7 @@ function WorkspaceScreenContent({
|
||||
() => createTerminalMutation.isPending || pendingTerminalCreateInput !== null,
|
||||
[createTerminalMutation.isPending, pendingTerminalCreateInput],
|
||||
);
|
||||
const showCreateBrowserTab = getIsElectron();
|
||||
const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]);
|
||||
const desktopFocusModeEnabled = useMemo(
|
||||
() => isFocusModeEnabled && !isMobile,
|
||||
@@ -2855,6 +2904,8 @@ function WorkspaceScreenContent({
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminalTab={handleCreateTerminal}
|
||||
onCreateBrowserTab={handleCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildDesktopPaneContentModel}
|
||||
onFocusPane={handleFocusPane}
|
||||
onSplitPane={handleSplitPane}
|
||||
@@ -2887,6 +2938,8 @@ function WorkspaceScreenContent({
|
||||
handleCloseOtherTabsInPane,
|
||||
handleCreateDraftTab,
|
||||
handleCreateTerminal,
|
||||
handleCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
buildDesktopPaneContentModel,
|
||||
handleFocusPane,
|
||||
handleSplitPane,
|
||||
@@ -2932,14 +2985,17 @@ function WorkspaceScreenContent({
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
@@ -2989,6 +3045,8 @@ function WorkspaceScreenContent({
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminalTab={handleCreateTerminal}
|
||||
onCreateBrowserTab={handleCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
disableCreateTerminal={createTerminalMutation.isPending}
|
||||
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
|
||||
@@ -81,6 +81,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return `workspace-draft-close-${tab.target.draftId}`;
|
||||
}
|
||||
if (tab.target.kind === "browser") {
|
||||
return `workspace-browser-close-${tab.target.browserId}`;
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
|
||||
}
|
||||
|
||||
45
packages/app/src/stores/browser-store.test.ts
Normal file
45
packages/app/src/stores/browser-store.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
177
packages/app/src/stores/browser-store.ts
Normal file
177
packages/app/src/stores/browser-store.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export interface BrowserRecord {
|
||||
browserId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
isLoading: boolean;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
faviconUrl: string | null;
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
type BrowserRecordPatch = Partial<Omit<BrowserRecord, "browserId" | "createdAt">>;
|
||||
|
||||
interface BrowserStoreState {
|
||||
browsersById: Record<string, BrowserRecord>;
|
||||
createBrowser: (input?: { initialUrl?: string }) => string;
|
||||
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
|
||||
removeBrowser: (browserId: string) => void;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeBrowserUrl(value: string | null | undefined): string {
|
||||
const trimmed = trimNonEmpty(value);
|
||||
if (!trimmed) {
|
||||
return "https://example.com";
|
||||
}
|
||||
if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) {
|
||||
return `http://${trimmed}`;
|
||||
}
|
||||
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("//")) {
|
||||
return `https:${trimmed}`;
|
||||
}
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
function createBrowserId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export const useBrowserStore = create<BrowserStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
browsersById: {},
|
||||
createBrowser: (input) => {
|
||||
const browserId = createBrowserId();
|
||||
const now = Date.now();
|
||||
const initialUrl = normalizeBrowserUrl(input?.initialUrl);
|
||||
|
||||
set((state) => ({
|
||||
browsersById: {
|
||||
...state.browsersById,
|
||||
[browserId]: {
|
||||
browserId,
|
||||
url: initialUrl,
|
||||
title: "",
|
||||
isLoading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
faviconUrl: null,
|
||||
lastError: null,
|
||||
createdAt: now,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return browserId;
|
||||
},
|
||||
updateBrowser: (browserId, patch) => {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const existing = state.browsersById[normalizedBrowserId];
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextRecord: BrowserRecord = {
|
||||
...existing,
|
||||
...patch,
|
||||
url: normalizeBrowserUrl(patch.url ?? existing.url),
|
||||
};
|
||||
|
||||
if (
|
||||
nextRecord.url === existing.url &&
|
||||
nextRecord.title === existing.title &&
|
||||
nextRecord.isLoading === existing.isLoading &&
|
||||
nextRecord.canGoBack === existing.canGoBack &&
|
||||
nextRecord.canGoForward === existing.canGoForward &&
|
||||
nextRecord.faviconUrl === existing.faviconUrl &&
|
||||
nextRecord.lastError === existing.lastError
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
browsersById: {
|
||||
...state.browsersById,
|
||||
[normalizedBrowserId]: nextRecord,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
removeBrowser: (browserId) => {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (!state.browsersById[normalizedBrowserId]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.browsersById };
|
||||
delete next[normalizedBrowserId];
|
||||
return { browsersById: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "workspace-browser-store",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
browsersById: Object.fromEntries(
|
||||
Object.entries(state.browsersById).map(([browserId, browser]) => [
|
||||
browserId,
|
||||
{ ...browser, isLoading: false, lastError: null },
|
||||
]),
|
||||
),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function getBrowserRecord(browserId: string): BrowserRecord | null {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null;
|
||||
}
|
||||
|
||||
export function createWorkspaceBrowser(input?: { initialUrl?: string }): {
|
||||
browserId: string;
|
||||
url: string;
|
||||
} {
|
||||
const browserId = useBrowserStore.getState().createBrowser(input);
|
||||
const record = getBrowserRecord(browserId);
|
||||
return {
|
||||
browserId,
|
||||
url: record?.url ?? normalizeBrowserUrl(input?.initialUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string {
|
||||
return normalizeBrowserUrl(value);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "setup"; workspaceId: string };
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ export function normalizeWorkspaceTabTarget(
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
}
|
||||
if (value.kind === "browser") {
|
||||
const browserId = trimNonEmpty(value.browserId);
|
||||
return browserId ? { kind: "browser", browserId } : null;
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
@@ -45,6 +49,9 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "browser" && right.kind === "browser") {
|
||||
return left.browserId === right.browserId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
@@ -64,6 +71,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
if (target.kind === "browser") {
|
||||
return `browser_${target.browserId}`;
|
||||
}
|
||||
if (target.kind === "setup") {
|
||||
return `setup_${target.workspaceId}`;
|
||||
}
|
||||
|
||||
@@ -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`""
|
||||
|
||||
@@ -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'"
|
||||
|
||||
17
packages/desktop/src/features/browser-webviews.ts
Normal file
17
packages/desktop/src/features/browser-webviews.ts
Normal 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;
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user