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:
co-authored by
Jason Kneen
parent
34b95e7f01
commit
906205f97a
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`""
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user