From 906205f97acb59a20e1c48463e086ac246ea6386 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 2 May 2026 19:28:40 +0800 Subject: [PATCH] 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 * 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 --- CLAUDE.md | 8 + package.json | 2 +- .../composer-workspace-attachments.tsx | 84 +- packages/app/src/attachments/types.ts | 38 +- .../src/components/browser-pane.electron.tsx | 1080 +++++++++++++++++ packages/app/src/components/browser-pane.tsx | 48 + .../app/src/components/browser-pane.web.tsx | 51 + packages/app/src/components/composer.test.tsx | 41 + packages/app/src/components/message.tsx | 2 + .../app/src/components/split-container.tsx | 23 + packages/app/src/desktop/host.ts | 5 + packages/app/src/panels/agent-panel.test.tsx | 3 + packages/app/src/panels/agent-panel.tsx | 3 + packages/app/src/panels/browser-panel.tsx | 77 ++ packages/app/src/panels/pane-context.test.ts | 2 + packages/app/src/panels/pane-context.tsx | 4 + packages/app/src/panels/register-panels.ts | 2 + .../workspace/workspace-desktop-tabs-row.tsx | 28 + .../workspace/workspace-draft-agent-tab.tsx | 3 + .../workspace/workspace-pane-content.test.tsx | 2 + .../workspace/workspace-pane-content.tsx | 5 +- .../screens/workspace/workspace-screen.tsx | 60 +- .../screens/workspace/workspace-tab-menu.ts | 3 + packages/app/src/stores/browser-store.test.ts | 45 + packages/app/src/stores/browser-store.ts | 177 +++ .../app/src/stores/workspace-tabs-store.ts | 1 + .../app/src/utils/workspace-tab-identity.ts | 10 + packages/desktop/scripts/dev.ps1 | 2 +- packages/desktop/scripts/dev.sh | 2 +- .../desktop/src/features/browser-webviews.ts | 17 + packages/desktop/src/features/menu.ts | 46 +- packages/desktop/src/main.ts | 119 ++ .../server/agent/prompt-attachments.test.ts | 11 + .../src/server/agent/prompt-attachments.ts | 3 + packages/server/src/shared/messages.ts | 8 + 35 files changed, 1990 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/components/browser-pane.electron.tsx create mode 100644 packages/app/src/components/browser-pane.tsx create mode 100644 packages/app/src/components/browser-pane.web.tsx create mode 100644 packages/app/src/panels/browser-panel.tsx create mode 100644 packages/app/src/stores/browser-store.test.ts create mode 100644 packages/app/src/stores/browser-store.ts create mode 100644 packages/desktop/src/features/browser-webviews.ts diff --git a/CLAUDE.md b/CLAUDE.md index b2210779d..27d2cce4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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. diff --git a/package.json b/package.json index 4901033ba..6e470ff2b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/src/attachments/composer-workspace-attachments.tsx b/packages/app/src/attachments/composer-workspace-attachments.tsx index 2bfb854ab..afe357cdc 100644 --- a/packages/app/src/attachments/composer-workspace-attachments.tsx +++ b/packages/app/src/attachments/composer-workspace-attachments.tsx @@ -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 ( @@ -90,6 +110,9 @@ function useWorkspaceAttachmentBinding({ onOpenWorkspaceAttachment, }: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding { const clearReviewDraft = useClearReviewDraft(); + const setWorkspaceAttachments = useWorkspaceAttachmentsStore( + (state) => state.setWorkspaceAttachments, + ); const [suppressedKeys, setSuppressedKeys] = useState([]); 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} > - + {attachment.kind === "browser_element" ? ( + + ) : ( + + )} {label} @@ -279,5 +332,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, })) as unknown as Record; +const ThemedMousePointer2 = withUnistyles(MousePointer2); const ThemedMessageSquareCode = withUnistyles(MessageSquareCode); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index b6f01658b..66dda83d5 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -17,10 +17,38 @@ export interface AttachmentMetadata { createdAt: number; } +export interface BrowserElementAttachment { + url: string; + selector: string; + tag: string; + text: string; + outerHTML: string; + computedStyles: Record; + 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; @@ -28,9 +56,15 @@ export type ComposerAttachment = commentCount: number; }; -export type UserComposerAttachment = Exclude; +export type UserComposerAttachment = Exclude< + ComposerAttachment, + { kind: "review" } | { kind: "browser_element" } +>; -export type WorkspaceComposerAttachment = Extract; +export type WorkspaceComposerAttachment = Extract< + ComposerAttachment, + { kind: "review" } | { kind: "browser_element" } +>; export type AttachmentDataSource = | { kind: "bytes"; bytes: Uint8Array } diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx new file mode 100644 index 000000000..90dc1db5d --- /dev/null +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -0,0 +1,1080 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + createElement, +} from "react"; +import { Pressable, Text, TextInput, View } from "react-native"; +import { ArrowLeft, ArrowRight, MousePointer2, RefreshCw } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + buildWorkspaceAttachmentScopeKey, + useWorkspaceAttachments, + useWorkspaceAttachmentsStore, +} from "@/attachments/workspace-attachments-store"; +import type { BrowserElementAttachment } from "@/attachments/types"; +import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { + getDesktopHost, + isElectronRuntime, + type DesktopBrowserShortcutEvent, +} from "@/desktop/host"; +import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store"; + +type ElectronWebview = HTMLElement & { + canGoBack?: () => boolean; + canGoForward?: () => boolean; + goBack?: () => void; + goForward?: () => void; + reload?: () => void; + stop?: () => void; + loadURL?: (url: string) => Promise; + getURL?: () => string; + openDevTools?: () => void; + executeJavaScript?: (code: string) => Promise; + focus?: () => void; + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; + removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; +}; + +type WebTextInput = TextInput & { + getNativeRef?: () => unknown; +}; + +type BrowserElementSelection = Omit & { + attributes?: Record; +}; + +const ERR_ABORTED = -3; +const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]); + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value; +} + +function getWebviewLoadErrorMessage(event: Event): string | null { + const details = event as Event & { + errorCode?: unknown; + errorDescription?: unknown; + isMainFrame?: unknown; + validatedURL?: unknown; + }; + if (details.isMainFrame === false || details.errorCode === ERR_ABORTED) { + return null; + } + + const description = + typeof details.errorDescription === "string" && details.errorDescription.trim() + ? details.errorDescription.trim() + : "Failed to load page"; + const url = + typeof details.validatedURL === "string" && details.validatedURL.trim() + ? details.validatedURL.trim() + : null; + + return url ? `${description}: ${url}` : description; +} + +function getLoadUrlRejectionMessage(error: unknown): string | null { + if (error instanceof Error && error.message.trim()) { + if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) { + return null; + } + return error.message.trim(); + } + if (typeof error === "string" && error.trim()) { + if (error.includes("ERR_ABORTED") || error.includes("ERR_BLOCKED_BY_CLIENT")) { + return null; + } + return error.trim(); + } + return "Failed to load page"; +} + +function getUnsafeNavigationMessage(url: string): string | null { + try { + const parsed = new URL(url); + if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") { + return null; + } + return `Blocked unsupported browser URL: ${parsed.protocol}`; + } catch { + return "Invalid browser URL"; + } +} + +function formatElementAttachment(selection: BrowserElementSelection): string { + const textPreview = truncateText(selection.text.trim(), 200); + const html = truncateText(selection.outerHTML.trim(), 800); + const parts: string[] = []; + + if (selection.reactSource?.fileName) { + const loc = [ + selection.reactSource.fileName, + selection.reactSource.lineNumber != null ? `:${selection.reactSource.lineNumber}` : "", + selection.reactSource.columnNumber != null ? `:${selection.reactSource.columnNumber}` : "", + ].join(""); + parts.push(`source: ${selection.reactSource.componentName ?? selection.tag} @ ${loc}`); + } + + parts.push(`selector: ${selection.selector}`); + + if (textPreview) { + parts.push(`text: ${JSON.stringify(textPreview)}`); + } + + parts.push(`size: ${selection.boundingRect.width}x${selection.boundingRect.height}`); + + const keyStyles = Object.entries(selection.computedStyles) + .filter(([key]) => + ["display", "position", "font-size", "color", "background-color"].includes(key), + ) + .map(([key, value]) => `${key}: ${value}`) + .join("; "); + if (keyStyles) { + parts.push(`styles: ${keyStyles}`); + } + + if (selection.parentChain.length > 0) { + parts.push(`parents: ${selection.parentChain.slice(0, 3).join(" > ")}`); + } + + return [ + ``, + parts.map((part) => ` ${part}`).join("\n"), + ` html: ${html}`, + ``, + ].join("\n"); +} + +function buildBrowserElementAttachment( + selection: BrowserElementSelection, +): BrowserElementAttachment { + return { + url: selection.url, + selector: selection.selector, + tag: selection.tag, + text: selection.text, + outerHTML: truncateText(selection.outerHTML, 2000), + computedStyles: selection.computedStyles, + boundingRect: selection.boundingRect, + reactSource: selection.reactSource, + parentChain: selection.parentChain, + children: selection.children, + formatted: formatElementAttachment(selection), + }; +} + +function buildBrowserAttachmentScopeKey(input: { + cwd: string | null; + serverId: string; + workspaceId: string; +}): string | null { + if (!input.cwd) { + return null; + } + return buildWorkspaceAttachmentScopeKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + cwd: input.cwd, + }); +} + +function executeWebviewJavaScript(webview: ElectronWebview, code: string): Promise { + if (!webview.isConnected) { + return Promise.resolve(null); + } + try { + return webview.executeJavaScript?.(code) ?? Promise.resolve(null); + } catch (error) { + return Promise.reject(error); + } +} + +function ignoreWebviewJavaScriptError() {} + +function destroyWebviewSelector(webview: ElectronWebview): void { + void executeWebviewJavaScript( + webview, + "if(window.__paseoSelector) window.__paseoSelector.destroy();", + ).catch(ignoreWebviewJavaScriptError); +} + +function clearWebviewSelector(webview: ElectronWebview): void { + void executeWebviewJavaScript( + webview, + "if(window.__paseoSelector) window.__paseoSelector.destroy(); window.__paseoSelectorResult = null;", + ).catch(ignoreWebviewJavaScriptError); +} + +function getTextInputNativeElement(current: WebTextInput | null): HTMLInputElement | null { + const native = current?.getNativeRef?.() ?? current; + return native instanceof HTMLInputElement ? native : null; +} + +function isBrowserShortcutKey(event: KeyboardEvent, key: "l" | "r"): boolean { + if (event.altKey || event.shiftKey) { + return false; + } + if (!event.metaKey && !event.ctrlKey) { + return false; + } + const eventKey = event.key.toLowerCase(); + return eventKey === key || event.code === `Key${key.toUpperCase()}`; +} + +function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrowserShortcutEvent { + if (!payload || typeof payload !== "object") { + return false; + } + const event = payload as Partial; + return event.action === "focus-url"; +} + +function startSelectorResultPolling(input: { + webview: ElectronWebview; + onSelection: (selection: BrowserElementSelection) => void; + onDone: () => void; +}): number { + const { webview, onSelection, onDone } = input; + const poll = window.setInterval(() => { + void (async () => { + try { + const raw = await executeWebviewJavaScript( + webview, + "JSON.stringify(window.__paseoSelectorResult || null)", + ); + const result = typeof raw === "string" ? JSON.parse(raw) : null; + if (!result) { + return; + } + window.clearInterval(poll); + onDone(); + await executeWebviewJavaScript(webview, "window.__paseoSelectorResult = null;"); + if (!result.__cancelled) { + onSelection(result as BrowserElementSelection); + } + } catch { + // Keep polling; cross-origin/webview timing can make this transient. + } + })(); + }, 200); + + return poll; +} + +// eslint-disable-next-line complexity +export function BrowserPane({ + browserId, + serverId, + workspaceId, + cwd, + isInteractive, + onFocusPane, +}: { + browserId: string; + serverId: string; + workspaceId: string; + cwd: string | null; + isInteractive?: boolean; + onFocusPane?: () => void; +}) { + const { theme } = useUnistyles(); + const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null); + const updateBrowser = useBrowserStore((state) => state.updateBrowser); + const webviewRef = useRef(null); + const webviewHostRef = useRef(null); + const urlInputRef = useRef(null); + const initialUrlRef = useRef(browser?.url ?? "https://example.com"); + const browserIdRef = useRef(browserId); + browserIdRef.current = browserId; + const browserRef = useRef(browser); + browserRef.current = browser; + const pendingNavigationUrlRef = useRef(null); + const domReadyRef = useRef(false); + const [selectorActive, setSelectorActive] = useState(false); + const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com"); + const workspaceAttachmentScopeKey = useMemo( + () => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }), + [cwd, serverId, workspaceId], + ); + const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey ?? ""); + const setWorkspaceAttachments = useWorkspaceAttachmentsStore( + (state) => state.setWorkspaceAttachments, + ); + const titleStyle = useMemo( + () => [styles.unavailableTitle, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const subtitleStyle = useMemo( + () => [styles.unavailableSubtitle, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + const urlInputStyle = useMemo( + () => [ + styles.urlInput, + { + color: theme.colors.foreground, + outlineStyle: "none", + } as object, + ], + [theme.colors.foreground], + ); + const errorTextStyle = useMemo( + () => [styles.metaError, { color: theme.colors.palette.red[500] }], + [theme.colors.palette.red], + ); + + useEffect(() => { + const nextUrl = browser?.url ?? "https://example.com"; + setDraftUrl((current) => (current === nextUrl ? current : nextUrl)); + }, [browser?.url]); + + const updateBrowserRef = useRef(updateBrowser); + updateBrowserRef.current = updateBrowser; + + const focusUrlBar = useCallback(() => { + urlInputRef.current?.focus(); + window.setTimeout(() => { + getTextInputNativeElement(urlInputRef.current)?.select(); + }, 0); + }, []); + + const syncNavigationState = useCallback((input?: { syncUrl?: boolean }) => { + const webview = webviewRef.current; + if (!webview || !domReadyRef.current) { + return; + } + + try { + const currentUrl = webview.getURL?.() ?? webview.getAttribute("src") ?? ""; + const patch = { + canGoBack: webview.canGoBack?.() ?? false, + canGoForward: webview.canGoForward?.() ?? false, + ...(input?.syncUrl === false + ? {} + : { url: normalizeWorkspaceBrowserUrl(pendingNavigationUrlRef.current ?? currentUrl) }), + }; + updateBrowserRef.current(browserIdRef.current, patch); + } catch { + // webview not yet attached + } + }, []); + + useEffect(() => { + if (!isElectronRuntime()) { + return; + } + + const host = webviewHostRef.current; + if (!host) { + return; + } + + host.replaceChildren(); + + const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(initialUrlRef.current); + const webview = document.createElement("webview") as ElectronWebview; + webviewRef.current = webview; + webview.setAttribute("partition", `persist:paseo-browser-${browserId}`); + webview.setAttribute("allowpopups", "true"); + webview.setAttribute("spellcheck", "false"); + webview.setAttribute("autosize", "on"); + webview.setAttribute( + "src", + initialUnsafeNavigationMessage ? "about:blank" : initialUrlRef.current, + ); + webview.style.display = "flex"; + webview.style.flex = "1"; + webview.style.width = "100%"; + webview.style.height = "100%"; + webview.style.border = "0"; + webview.style.background = "transparent"; + + const handleStartLoading = () => { + updateBrowser(browserId, { isLoading: true, lastError: null }); + syncNavigationState({ syncUrl: false }); + }; + const handleStopLoading = () => { + updateBrowser(browserId, { isLoading: false }); + syncNavigationState(); + }; + const handleNavigate = (event: Event) => { + const nextUrl = + typeof (event as Event & { url?: unknown }).url === "string" + ? ((event as Event & { url?: string }).url ?? "") + : (webview.getURL?.() ?? webview.getAttribute("src") ?? ""); + const normalized = normalizeWorkspaceBrowserUrl(nextUrl); + const previousUrl = browserRef.current?.url ?? initialUrlRef.current; + pendingNavigationUrlRef.current = null; + updateBrowser(browserIdRef.current, { + url: normalized, + ...(normalized !== previousUrl ? { faviconUrl: null } : {}), + lastError: null, + }); + setDraftUrl((current) => { + return current === normalized ? current : normalized; + }); + syncNavigationState(); + }; + const handleWillNavigate = (event: Event) => { + const nextUrl = + typeof (event as Event & { url?: unknown }).url === "string" + ? ((event as Event & { url?: string }).url ?? "") + : ""; + if (!nextUrl) { + return; + } + const normalized = normalizeWorkspaceBrowserUrl(nextUrl); + pendingNavigationUrlRef.current = normalized; + updateBrowserRef.current(browserIdRef.current, { + url: normalized, + ...(normalized !== browserRef.current?.url ? { faviconUrl: null } : {}), + lastError: null, + }); + setDraftUrl((current) => (current === normalized ? current : normalized)); + }; + const handleTitleUpdated = (event: Event) => { + const title = + typeof (event as Event & { title?: unknown }).title === "string" + ? ((event as Event & { title?: string }).title ?? "") + : ""; + updateBrowserRef.current(browserIdRef.current, { title }); + }; + const handleFaviconUpdated = (event: Event) => { + const favicons = Array.isArray((event as Event & { favicons?: unknown[] }).favicons) + ? (((event as Event & { favicons?: string[] }).favicons as string[] | undefined) ?? []) + : []; + updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null }); + }; + const handleLoadFailed = (event: Event) => { + const message = getWebviewLoadErrorMessage(event); + if (!message) { + return; + } + updateBrowserRef.current(browserIdRef.current, { + isLoading: false, + lastError: message, + }); + }; + const handleDomReady = () => { + domReadyRef.current = true; + syncNavigationState(); + }; + const handleWebviewFocus = () => { + onFocusPane?.(); + }; + + webview.addEventListener("did-start-loading", handleStartLoading); + webview.addEventListener("did-stop-loading", handleStopLoading); + webview.addEventListener("will-navigate", handleWillNavigate); + webview.addEventListener("did-navigate", handleNavigate); + webview.addEventListener("did-navigate-in-page", handleNavigate); + webview.addEventListener("page-title-updated", handleTitleUpdated); + webview.addEventListener("page-favicon-updated", handleFaviconUpdated); + webview.addEventListener("did-fail-load", handleLoadFailed); + webview.addEventListener("dom-ready", handleDomReady); + webview.addEventListener("focus", handleWebviewFocus); + webview.addEventListener("mousedown", handleWebviewFocus); + + host.appendChild(webview); + if (initialUnsafeNavigationMessage) { + updateBrowserRef.current(browserIdRef.current, { + isLoading: false, + lastError: initialUnsafeNavigationMessage, + }); + } + + return () => { + webview.removeEventListener("did-start-loading", handleStartLoading); + webview.removeEventListener("did-stop-loading", handleStopLoading); + webview.removeEventListener("will-navigate", handleWillNavigate); + webview.removeEventListener("did-navigate", handleNavigate); + webview.removeEventListener("did-navigate-in-page", handleNavigate); + webview.removeEventListener("page-title-updated", handleTitleUpdated); + webview.removeEventListener("page-favicon-updated", handleFaviconUpdated); + webview.removeEventListener("did-fail-load", handleLoadFailed); + webview.removeEventListener("dom-ready", handleDomReady); + webview.removeEventListener("focus", handleWebviewFocus); + webview.removeEventListener("mousedown", handleWebviewFocus); + if (host.contains(webview)) { + host.removeChild(webview); + } + if (webviewRef.current === webview) { + webviewRef.current = null; + } + domReadyRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [browserId, onFocusPane]); + + const navigate = useCallback((nextUrl: string) => { + const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl); + const webview = webviewRef.current; + const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl); + const previousUrl = browserRef.current?.url ?? initialUrlRef.current; + pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl; + updateBrowserRef.current(browserIdRef.current, { + url: normalizedUrl, + isLoading: unsafeNavigationMessage === null, + ...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}), + lastError: null, + }); + setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl)); + if (unsafeNavigationMessage) { + updateBrowserRef.current(browserIdRef.current, { + isLoading: false, + lastError: unsafeNavigationMessage, + }); + return; + } + if (webview?.loadURL) { + void webview.loadURL(normalizedUrl).catch((error: unknown) => { + const message = getLoadUrlRejectionMessage(error); + if (!message) { + return; + } + updateBrowserRef.current(browserIdRef.current, { + isLoading: false, + lastError: message, + }); + }); + return; + } + if (webview) { + webview.setAttribute("src", normalizedUrl); + } + }, []); + + const handleBack = useCallback(() => { + webviewRef.current?.goBack?.(); + syncNavigationState(); + }, [syncNavigationState]); + + const handleForward = useCallback(() => { + webviewRef.current?.goForward?.(); + syncNavigationState(); + }, [syncNavigationState]); + + const handleRefresh = useCallback(() => { + if (browser?.isLoading) { + webviewRef.current?.stop?.(); + updateBrowser(browserId, { isLoading: false }); + return; + } + webviewRef.current?.reload?.(); + }, [browser?.isLoading, browserId, updateBrowser]); + + useEffect(() => { + if (!isElectronRuntime() || !isInteractive) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (isBrowserShortcutKey(event, "l")) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + focusUrlBar(); + return; + } + if (isBrowserShortcutKey(event, "r")) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + handleRefresh(); + } + }; + + window.addEventListener("keydown", handleKeyDown, true); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [focusUrlBar, handleRefresh, isInteractive]); + + useEffect(() => { + if (!isElectronRuntime()) { + return; + } + const unsubscribe = getDesktopHost()?.events?.on?.("browser-shortcut", (payload) => { + if (!isDesktopBrowserShortcutEvent(payload)) { + return; + } + if (payload.browserId) { + if (payload.browserId !== browserIdRef.current) { + return; + } + focusUrlBar(); + return; + } + if (!isInteractive) { + return; + } + focusUrlBar(); + }); + + if (typeof unsubscribe === "function") { + return unsubscribe; + } + return () => { + void unsubscribe?.then((dispose) => dispose()); + }; + }, [focusUrlBar, isInteractive]); + + const handleNavigateDraftUrl = useCallback(() => { + navigate(draftUrl); + }, [draftUrl, navigate]); + + const addElementAttachment = useCallback( + (selection: BrowserElementSelection) => { + if (!workspaceAttachmentScopeKey) { + return; + } + setWorkspaceAttachments({ + scopeKey: workspaceAttachmentScopeKey, + attachments: [ + ...workspaceAttachments, + { + kind: "browser_element", + attachment: buildBrowserElementAttachment(selection), + }, + ], + }); + }, + [setWorkspaceAttachments, workspaceAttachmentScopeKey, workspaceAttachments], + ); + + const startElementSelector = useCallback(() => { + const webview = webviewRef.current; + if (!webview || !domReadyRef.current || !workspaceAttachmentScopeKey) return; + setSelectorActive(true); + + const js = ` + (function() { + if (window.__paseoSelector) { window.__paseoSelector.destroy(); } + var overlay = null; + var style = document.createElement('style'); + style.textContent = [ + '.__paseo-hover { outline: 2px solid #3b82f6 !important; outline-offset: 2px !important; cursor: crosshair !important; }', + '.__paseo-select-mode, .__paseo-select-mode * { cursor: crosshair !important; pointer-events: auto !important; user-select: none !important; }', + '.__paseo-select-mode *, .__paseo-select-mode *::before, .__paseo-select-mode *::after { animation: none !important; transition: none !important; }', + '.__paseo-select-mode a, .__paseo-select-mode button, .__paseo-select-mode input, .__paseo-select-mode select, .__paseo-select-mode textarea, .__paseo-select-mode [role="button"], .__paseo-select-mode [onclick] { pointer-events: none !important; }', + '.__paseo-select-mode iframe, .__paseo-select-mode video, .__paseo-select-mode audio { pointer-events: none !important; }', + ].join('\\n'); + document.head.appendChild(style); + document.documentElement.classList.add('__paseo-select-mode'); + var last = null; + function onMove(e) { + e.preventDefault(); + e.stopPropagation(); + if (last) last.classList.remove('__paseo-hover'); + e.target.classList.add('__paseo-hover'); + last = e.target; + } + function buildSelector(el) { + if (el.id) return '#' + el.id; + var path = []; + while (el && el.nodeType === 1) { + var seg = el.tagName.toLowerCase(); + if (el.id) { path.unshift('#' + el.id); break; } + var sib = el, nth = 1; + while (sib = sib.previousElementSibling) { if (sib.tagName === el.tagName) nth++; } + if (nth > 1) seg += ':nth-of-type(' + nth + ')'; + path.unshift(seg); + el = el.parentElement; + } + return path.join(' > '); + } + function getReactSource(el) { + var keys = Object.keys(el); + for (var i = 0; i < keys.length; i++) { + if (keys[i].startsWith('__reactFiber$') || keys[i].startsWith('__reactInternalInstance$')) { + var fiber = el[keys[i]]; + while (fiber) { + if (fiber._debugSource) { + return { + fileName: fiber._debugSource.fileName || null, + lineNumber: fiber._debugSource.lineNumber || null, + columnNumber: fiber._debugSource.columnNumber || null, + componentName: (fiber.type && (typeof fiber.type === 'string' ? fiber.type : fiber.type.displayName || fiber.type.name)) || null + }; + } + if (fiber._debugOwner) { fiber = fiber._debugOwner; } + else if (fiber.return) { fiber = fiber.return; } + else break; + } + } + } + return null; + } + function getParentChain(el, depth) { + var chain = []; + var cur = el.parentElement; + for (var i = 0; i < (depth || 5) && cur; i++) { + var desc = cur.tagName.toLowerCase(); + if (cur.id) desc += '#' + cur.id; + if (cur.className && typeof cur.className === 'string') { var cls = cur.className.trim().replace(/ +/g, ' ').split(' ').slice(0,2).join('.'); if (cls) desc += '.' + cls; } + chain.push(desc); + cur = cur.parentElement; + } + return chain; + } + function getChildSummary(el, max) { + var kids = []; + for (var i = 0; i < Math.min(el.children.length, max || 8); i++) { + var c = el.children[i]; + var desc = c.tagName.toLowerCase(); + if (c.id) desc += '#' + c.id; + kids.push(desc); + } + if (el.children.length > (max || 8)) kids.push('...(' + el.children.length + ' total)'); + return kids; + } + function getRelevantStyles(el) { + var cs = window.getComputedStyle(el); + var pick = ['display','position','width','height','color','background-color','font-size','font-family','padding','margin','border','flex','grid-template-columns','gap','overflow','opacity','z-index']; + var out = {}; + pick.forEach(function(p) { + var v = cs.getPropertyValue(p); + if (v && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') out[p] = v; + }); + return out; + } + function onClick(e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + var el = e.target; + if (last) last.classList.remove('__paseo-hover'); + var attrs = {}; + for (var i = 0; i < el.attributes.length; i++) { + attrs[el.attributes[i].name] = el.attributes[i].value; + } + var rect = el.getBoundingClientRect(); + var result = { + tag: el.tagName.toLowerCase(), + text: (el.innerText || '').substring(0, 500), + selector: buildSelector(el), + attributes: attrs, + url: location.href, + outerHTML: el.outerHTML.substring(0, 2000), + computedStyles: getRelevantStyles(el), + boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + reactSource: getReactSource(el), + parentChain: getParentChain(el, 5), + children: getChildSummary(el, 8) + }; + destroy(); + window.__paseoSelectorResult = result; + } + function onKey(e) { + if (e.key === 'Escape') { destroy(); window.__paseoSelectorResult = { __cancelled: true }; } + } + function blockEvent(e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + function destroy() { + document.removeEventListener('mousemove', onMove, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('keydown', onKey, true); + document.removeEventListener('mousedown', blockEvent, true); + document.removeEventListener('mouseup', blockEvent, true); + document.removeEventListener('pointerdown', blockEvent, true); + document.removeEventListener('pointerup', blockEvent, true); + document.removeEventListener('touchstart', blockEvent, true); + document.removeEventListener('touchend', blockEvent, true); + document.removeEventListener('focus', blockEvent, true); + document.removeEventListener('submit', blockEvent, true); + document.documentElement.classList.remove('__paseo-select-mode'); + if (last) last.classList.remove('__paseo-hover'); + style.remove(); + window.__paseoSelector = null; + } + document.addEventListener('mousemove', onMove, true); + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKey, true); + document.addEventListener('mousedown', blockEvent, true); + document.addEventListener('mouseup', blockEvent, true); + document.addEventListener('pointerdown', blockEvent, true); + document.addEventListener('pointerup', blockEvent, true); + document.addEventListener('touchstart', blockEvent, true); + document.addEventListener('touchend', blockEvent, true); + document.addEventListener('focus', blockEvent, true); + document.addEventListener('submit', blockEvent, true); + window.__paseoSelector = { destroy: destroy }; + })() + `; + + try { + void executeWebviewJavaScript(webview, js) + .then(() => { + const poll = startSelectorResultPolling({ + webview, + onSelection: addElementAttachment, + onDone: () => setSelectorActive(false), + }); + window.setTimeout(() => { + window.clearInterval(poll); + setSelectorActive(false); + if (webviewRef.current !== webview || !domReadyRef.current) { + return; + } + destroyWebviewSelector(webview); + }, 30000); + return undefined; + }) + .catch(() => { + setSelectorActive(false); + }); + } catch { + setSelectorActive(false); + } + }, [addElementAttachment, workspaceAttachmentScopeKey]); + + const cancelElementSelector = useCallback(() => { + const webview = webviewRef.current; + setSelectorActive(false); + if (webview && domReadyRef.current) { + try { + clearWebviewSelector(webview); + } catch {} + } + }, []); + + const handleToggleElementSelector = useCallback(() => { + if (selectorActive) { + cancelElementSelector(); + return; + } + startElementSelector(); + }, [cancelElementSelector, selectorActive, startElementSelector]); + + const baseIconButtonStyle = useCallback( + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ + styles.iconButton, + (hovered || pressed) && styles.iconButtonHovered, + ], + [], + ); + const backIconButtonStyle = useCallback( + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ + styles.iconButton, + (hovered || pressed) && styles.iconButtonHovered, + !browser?.canGoBack && styles.iconButtonDisabled, + ], + [browser?.canGoBack], + ); + const forwardIconButtonStyle = useCallback( + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ + styles.iconButton, + (hovered || pressed) && styles.iconButtonHovered, + !browser?.canGoForward && styles.iconButtonDisabled, + ], + [browser?.canGoForward], + ); + const selectorIconButtonStyle = useCallback( + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ + styles.iconButton, + selectorActive && styles.selectorActiveButton, + (hovered || pressed) && styles.iconButtonHovered, + ], + [selectorActive], + ); + + const webviewHostStyle = useMemo( + () => ({ + display: "flex", + flex: 1, + width: "100%", + height: "100%", + minHeight: 0, + background: theme.colors.surface0, + }), + [theme.colors.surface0], + ); + + if (!isElectronRuntime()) { + return ( + + Browser is desktop-only + + Open this workspace in Electron to use the built-in browser. + + + ); + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + {browser?.lastError ? ( + + + {browser.lastError} + + + ) : null} + + {createElement("div", { + ref: (node: HTMLDivElement | null) => { + webviewHostRef.current = node; + }, + style: webviewHostStyle, + })} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.surface0, + }, + chromeRow: { + height: WORKSPACE_SECONDARY_HEADER_HEIGHT, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + backgroundColor: theme.colors.surface0, + }, + chromeLeft: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + chromeRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + iconButton: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + }, + selectorActiveButton: { + backgroundColor: `${String(theme.colors.accent)}20`, + }, + iconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + iconButtonDisabled: { + opacity: 0.45, + }, + urlBarWrap: { + flex: 1, + minWidth: 0, + height: 28, + borderRadius: theme.borderRadius.md, + paddingHorizontal: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.border, + }, + urlInput: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.sm, + paddingVertical: 0, + paddingHorizontal: 0, + }, + errorRow: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + backgroundColor: theme.colors.surface0, + }, + metaError: { + fontSize: theme.fontSize.xs, + }, + webviewWrap: { + flex: 1, + minHeight: 0, + overflow: "hidden", + }, + unavailableState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 24, + gap: 8, + }, + unavailableTitle: { + fontSize: 16, + fontWeight: "600", + }, + unavailableSubtitle: { + fontSize: 12, + }, +})); diff --git a/packages/app/src/components/browser-pane.tsx b/packages/app/src/components/browser-pane.tsx new file mode 100644 index 000000000..3afe064a9 --- /dev/null +++ b/packages/app/src/components/browser-pane.tsx @@ -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 ( + + Browser is desktop-only + Browser session {browserId} + + ); +} + +const styles = StyleSheet.create(() => ({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 16, + }, + title: { + fontSize: 16, + fontWeight: "600", + }, + subtitle: { + fontSize: 12, + }, +})); diff --git a/packages/app/src/components/browser-pane.web.tsx b/packages/app/src/components/browser-pane.web.tsx new file mode 100644 index 000000000..f87cdb453 --- /dev/null +++ b/packages/app/src/components/browser-pane.web.tsx @@ -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 ( + + Browser is desktop-only + + Open this workspace in Electron to use the built-in browser. + + Browser session {browserId} + + ); +} + +const styles = StyleSheet.create(() => ({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 16, + }, + title: { + fontSize: 16, + fontWeight: "600", + }, + subtitle: { + fontSize: 12, + }, +})); diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx index c86341ae9..6a421294f 100644 --- a/packages/app/src/components/composer.test.tsx +++ b/packages/app/src/components/composer.test.tsx @@ -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; type ReviewAttachment = Extract; +type BrowserElementComposerAttachment = Extract; 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: '', + 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: 'button.primary', + }, + }; +} + 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."); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index c2d5fd4b4..f2f2f6ea0 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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"; } } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 02247d601..68ce29924 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -91,6 +91,8 @@ interface SplitContainerProps { onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | 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 ( @@ -192,6 +199,7 @@ const MountedTabSlot = memo(function MountedTabSlot({ content={content} isWorkspaceFocused={isWorkspaceFocused} isPaneFocused={isPaneFocused} + onFocusPane={handleFocusPane} /> ); @@ -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} /> ); diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index edbb2b0cf..c3b9fceaa 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -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) => Promise; } diff --git a/packages/app/src/panels/agent-panel.test.tsx b/packages/app/src/panels/agent-panel.test.tsx index 16928a8ab..c26022cf2 100644 --- a/packages/app/src/panels/agent-panel.test.tsx +++ b/packages/app/src/panels/agent-panel.test.tsx @@ -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; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 4aefa9260..a84884515 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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, diff --git a/packages/app/src/panels/browser-panel.tsx b/packages/app/src/panels/browser-panel.tsx new file mode 100644 index 000000000..037ce487f --- /dev/null +++ b/packages/app/src/panels/browser-panel.tsx @@ -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 ; + } + + return ; + }; +} + +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 ( + + ); +} + +export const browserPanelRegistration: PanelRegistration<"browser"> = { + kind: "browser", + component: BrowserPanel, + useDescriptor: useBrowserPanelDescriptor, +}; diff --git a/packages/app/src/panels/pane-context.test.ts b/packages/app/src/panels/pane-context.test.ts index 9d63c5784..5d3ad1b84 100644 --- a/packages/app/src/panels/pane-context.test.ts +++ b/packages/app/src/panels/pane-context.test.ts @@ -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), }); }); }); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index d4c3a27cd..c39cc10a0 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -17,19 +17,23 @@ export interface PaneFocusContextValue { isWorkspaceFocused: boolean; isPaneFocused: boolean; isInteractive: boolean; + focusPane(): void; } const PaneContext = createContext(null); const PaneFocusContext = createContext(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, }; } diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index dfc14f7fe..ebcf73428 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -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; } diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index c174f16bf..5b75de7a9 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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; 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({ + {showCreateBrowserTab ? ( + + + + + + + New browser tab + + + + ) : null} {showPaneSplitActions ? ( <> diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index be647367f..550f18521 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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, diff --git a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx index a441492ac..c4c422809 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx @@ -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), }); }); }); diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 91593054e..f0d9b3ab8 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -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 ( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 69c386693..f38de49d3 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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 = ; const MENU_NEW_TERMINAL_ICON = ; +const MENU_NEW_BROWSER_ICON = ; const MENU_COPY_ICON = ; const MENU_SETTINGS_ICON = ; @@ -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 + {showCreateBrowserTab ? ( + + New browser tab + + ) : null} 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} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index e5fff4b8d..da57221a5 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -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)}`; } diff --git a/packages/app/src/stores/browser-store.test.ts b/packages/app/src/stores/browser-store.test.ts new file mode 100644 index 000000000..d7d039656 --- /dev/null +++ b/packages/app/src/stores/browser-store.test.ts @@ -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", + ); + }); +}); diff --git a/packages/app/src/stores/browser-store.ts b/packages/app/src/stores/browser-store.ts new file mode 100644 index 000000000..e18d359cb --- /dev/null +++ b/packages/app/src/stores/browser-store.ts @@ -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>; + +interface BrowserStoreState { + browsersById: Record; + 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()( + 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); +} diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 85ef51940..594993afe 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -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 }; diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 87d6085f8..1a6e0f95a 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -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}`; } diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 580aabd9f..d6a7eefaf 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -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`"" diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index 8683d2e7b..3505168c2 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -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'" diff --git a/packages/desktop/src/features/browser-webviews.ts b/packages/desktop/src/features/browser-webviews.ts new file mode 100644 index 000000000..3cce3480b --- /dev/null +++ b/packages/desktop/src/features/browser-webviews.ts @@ -0,0 +1,17 @@ +import type { WebContents } from "electron"; + +const browserIdsByWebContentsId = new Map(); + +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; +} diff --git a/packages/desktop/src/features/menu.ts b/packages/desktop/src/features/menu.ts index 945f70028..e036c3d71 100644 --- a/packages/desktop/src/features/menu.ts +++ b/packages/desktop/src/features/menu.ts @@ -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" }, diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 7656759b2..731e73408 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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 { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, + webviewTag: true, }, }); @@ -217,6 +271,71 @@ async function createMainWindow(): Promise { 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(); diff --git a/packages/server/src/server/agent/prompt-attachments.test.ts b/packages/server/src/server/agent/prompt-attachments.test.ts index 3c59ea40f..3f5a5a69c 100644 --- a/packages/server/src/server/agent/prompt-attachments.test.ts +++ b/packages/server/src/server/agent/prompt-attachments.test.ts @@ -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: "button.primary", + }), + ).toBe("button.primary"); + }); + it("returns undefined when firstAgentContext is empty", () => { expect(buildAgentBranchNameSeed(undefined)).toBeUndefined(); expect(buildAgentBranchNameSeed({})).toBeUndefined(); diff --git a/packages/server/src/server/agent/prompt-attachments.ts b/packages/server/src/server/agent/prompt-attachments.ts index bcdcea70d..ea432edfe 100644 --- a/packages/server/src/server/agent/prompt-attachments.ts +++ b/packages/server/src/server/agent/prompt-attachments.ts @@ -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) { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 7d610d9b4..59272bb6e 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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, ]);