diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 2d90785d513..69ad8c72285 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -98,7 +98,6 @@ inline src/renderer/src/components/UpdateCard.tsx inline src/renderer/src/components/WorktreeJumpPalette.tsx inline src/renderer/src/components/activity/ActivityPrototypePage.tsx inline src/renderer/src/components/automations/AutomationsPage.tsx -inline src/renderer/src/components/browser-pane/BrowserPane.tsx inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/EditorContent.tsx inline src/renderer/src/components/editor/IpynbViewer.tsx diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index fe4d1c1fdad..c3370d8a1c3 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2667,19 +2667,19 @@ "invariant": "A current remote browser stream that closes must retry transient failures on one counted, capped backoff chain. Cancellation, pane or runtime supersession, missing pages, and permanent failures stop that chain without stale state mutation. A stream that stays healthy refills the budget; a silent or flapping stream cannot retry forever. Exhaustion and every non-retrying failure leave an actionable Reconnect control, and manual reconnect creates a fresh stream without resurrecting stale subscriptions.", "oracle": "With one fake clock and exact pane, operation, stream, page, and runtime identities, drop a confirmed stream and require the counted delay sequence, one active chain, stale-token rejection, exact cancellation, full cleanup, and either a new live subscription or one stopped state with Reconnect after exhaustion. Inject transport error without close, close racing rejection, never-ready subscriptions, ready-then-end flapping, viewport supersession, missing pages, and permanent failures. In a separate paired client against an isolated headless Orca host, paint one remote frame, disconnect the owning runtime, require the bounded-retry notice and Reconnect control, reconnect, click once, and require a different frame object URL from the recovered stream.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts", "pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ - "src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts", - "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts", - "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts", - "src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts", + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts", + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts", + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts", "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts" ], "assertionRefs": [ { - "file": "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts", + "file": "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts", "assertions": [ "transient failures retry until recovery on counted delays", "budget exhaustion stops exactly once", @@ -2688,7 +2688,7 @@ ] }, { - "file": "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts", + "file": "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts", "assertions": [ "dropped, silent, and flapping streams converge on a bounded retry or actionable stop", "stale, superseded, missing-page, and permanent-failure work cannot mutate the current pane", @@ -2710,7 +2710,7 @@ "date": "2026-08-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts", "result": "passed", "durationSeconds": 0.36, "summary": "Four focused files passed 61 deterministic error, status, scheduler, lifecycle, in-flight ownership, stale-rejection, liveness, and cleanup tests." @@ -9119,7 +9119,7 @@ "oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ "src/main/runtime/quarter-circle-title-send-authorization.test.ts", @@ -9132,7 +9132,7 @@ "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", "src/main/daemon/pty-subprocess.test.ts", "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts", - "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx" ], "assertionRefs": [ { @@ -9202,7 +9202,7 @@ "assertions": ["selected active-agent note sends retain guarded paste and submit routing"] }, { - "file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "file": "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", "assertions": [ "browser annotation send content routes through review-notes send so existing agent sessions remain selectable", "both browser annotation send surfaces wire the shared menu content" @@ -9223,7 +9223,7 @@ "date": "2026-07-11", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx", "result": "passed", "durationSeconds": 9.58, "summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 0999028ce29..82116fb6392 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -42,13 +42,13 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' -import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverlayLayer' +import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' import { isBrowserAutomationVisible, onBrowserAutomationVisibilityChange, useBrowserAutomationVisibilityForAny -} from './browser-pane/browser-automation-visibility' +} from './browser-pane/host-guest/browser-automation-visibility' import { isBrowserPageMobileDriven, onBrowserDriverChange, @@ -57,7 +57,7 @@ import { import { useAnyBrowserGuestNeedsPaint, useWorktreeBrowserPageIds -} from './browser-pane/browser-guest-paint-retention' +} from './browser-pane/host-guest/browser-guest-paint-retention' import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' import { collectBrowserWebviewIds, @@ -70,12 +70,12 @@ import { selectBrowserGuestEvictionWorktreeIds, touchBrowserGuestWorktreeRecency, worktreeHoldsLiveBrowserGuests -} from './browser-pane/browser-guest-worktree-retention' +} from './browser-pane/host-guest/browser-guest-worktree-retention' import { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking -} from './browser-pane/browser-page-download-activity' -import { hasLiveBrowserGuest } from './browser-pane/webview-registry' +} from './browser-pane/navigate/browser-page-download-activity' +import { hasLiveBrowserGuest } from './browser-pane/host-guest/webview-registry' import { handleSwitchRecentTab, handleSwitchTab, diff --git a/src/renderer/src/components/TerminalWorkbenchContainer.test.tsx b/src/renderer/src/components/TerminalWorkbenchContainer.test.tsx index 8aff4765206..d944b8537c9 100644 --- a/src/renderer/src/components/TerminalWorkbenchContainer.test.tsx +++ b/src/renderer/src/components/TerminalWorkbenchContainer.test.tsx @@ -20,7 +20,7 @@ vi.mock('../store', () => ({ // the wiring under test unproven, which is the whole point of this file. const { setDriverForBrowserPage } = await import('../lib/pane-manager/browser-mobile-driver-state') const { acquireBrowserAutomationVisibility, releaseBrowserAutomationVisibility } = - await import('./browser-pane/browser-automation-visibility') + await import('./browser-pane/host-guest/browser-automation-visibility') const { TerminalWorkbenchContainer } = await import('./TerminalWorkbenchContainer') const PAGE_ID = 'page-1' diff --git a/src/renderer/src/components/TerminalWorkbenchContainer.tsx b/src/renderer/src/components/TerminalWorkbenchContainer.tsx index a3b54fb33e8..a1c9ce6c6f4 100644 --- a/src/renderer/src/components/TerminalWorkbenchContainer.tsx +++ b/src/renderer/src/components/TerminalWorkbenchContainer.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useAnyBrowserGuestNeedsPaint } from './browser-pane/browser-guest-paint-retention' +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' // Why: the outermost ancestor of every browser . Parking it with `hidden` whenever // the user leaves the workspace view also stops the guest compositing, which silently kills diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 1b104faf3a5..fdb4873c258 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -130,7 +130,7 @@ import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, queueBrowserFocusRequest -} from '@/components/browser-pane/browser-focus' +} from '@/components/browser-pane/host-guest/browser-focus' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' diff --git a/src/renderer/src/components/artifacts/ArtifactPreview.tsx b/src/renderer/src/components/artifacts/ArtifactPreview.tsx index 69b8d26abf5..a2ee0605f7f 100644 --- a/src/renderer/src/components/artifacts/ArtifactPreview.tsx +++ b/src/renderer/src/components/artifacts/ArtifactPreview.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { AlertCircle, Loader2 } from 'lucide-react' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' -import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/webview-registry' +import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry' import { translate } from '@/i18n/i18n' type PreviewState = 'loading' | 'ready' | 'unavailable' diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx index 0d17baed21b..b7c210c3818 100644 --- a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -20,8 +20,8 @@ vi.mock('../../store', () => ({ useAppStore: appStoreStub() })) vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) import { BrowserCookieImportDisclosure } from './BrowserCookieImportDisclosure' -import { BrowserImportHintButton } from './browser-pane/BrowserImportHintButton' -import { BrowserToolbarMenuDropdown } from './browser-pane/browser-toolbar-menu-dropdown' +import { BrowserImportHintButton } from './browser-pane/assemble-chrome/BrowserImportHintButton' +import { BrowserToolbarMenuDropdown } from './browser-pane/assemble-chrome/browser-toolbar-menu-dropdown' import { BrowserProfileRow } from './settings/BrowserProfileRow' import { BrowserUseCookieImportStep } from './settings/BrowserUseCookieImportStep' diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 064d2e46d30..aba48a4f6a7 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -1,5739 +1,2 @@ -/* eslint-disable max-lines */ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: BrowserPane synchronizes Electron webviews, remote browser drivers, streams, downloads, and annotation overlays; those external lifecycles cannot be derived during render. */ -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type DragEvent -} from 'react' -import { createPortal } from 'react-dom' -import { cn } from '@/lib/utils' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context' -import { detectLanguage } from '@/lib/language-detect' -import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' -import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview' -import { - getWorkspaceFileDragRejectionMessage, - readWorkspaceFileDragPaths, - WORKSPACE_FILE_PATH_MIME -} from '@/lib/workspace-file-drag' -import { - ArrowLeft, - ArrowRight, - CircleCheck, - Copy, - CornerDownLeft, - Crosshair, - Download, - ExternalLink, - FolderOpen, - Globe, - Image, - Loader2, - MessageCircleQuestionMark, - MessageSquarePlus, - OctagonX, - PencilLine, - RefreshCw, - Send, - SquareCode, - Trash2, - X -} from 'lucide-react' -import { Button } from '@/components/ui/button' -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Label } from '@/components/ui/label' -import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' -import { useAppStore } from '@/store' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' -import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { getOrcaProfileBrowserDefaultPartition } from '../../../../shared/orca-profiles' -import type { - BrowserCertificateProceedResult, - BrowserLoadError, - BrowserPage as BrowserPageState, - BrowserWorkspace as BrowserWorkspaceState -} from '../../../../shared/browser-workspace-types' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl, - redactKagiSessionToken, - resolveRemoteFailureExternalUrl, - toHttpsRecoveryUrl -} from '../../../../shared/browser-url' -import { keybindingMatchesAction } from '../../../../shared/keybindings' -import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { - browserViewportPresetToOverride, - getBrowserViewportPreset -} from '../../../../shared/browser-viewport-presets' -import { getLiveBrowserUrl, rememberLiveBrowserUrl, seedLiveBrowserUrl } from './browser-runtime' -import { ensureBrowserPageWebview } from './browser-page-webview' -import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' -import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' -import type { - RemoteBrowserOperationToken, - RemoteBrowserStreamToken, - RemoteBrowserViewportSize -} from './remote-browser-stream-tokens' -import { - destroyPersistentWebview, - isBrowserPageRendererRecoveryPending, - moveFocusToRendererBeforeWebviewDetach, - replacePersistentWebview, - registeredWebContentsIds -} from './webview-registry' -import { - applyBrowserPageViewportLayout, - ensureBrowserPageViewport, - getBrowserOverlaySlotViewport, - parkBrowserPageViewport, - subscribeBrowserOverlaySlotViewport, - syncBrowserPageChromeInset -} from './browser-page-viewport' -import { useBrowserAutomationVisiblePageIds } from './browser-automation-visibility' -import type { - BrowserDownloadRequestedEvent, - BrowserDownloadProgressEvent, - BrowserDownloadFinishedEvent -} from '../../../../shared/browser-guest-events' -import { - GRAB_BUDGET, - type BrowserAnnotationIntent, - type BrowserAnnotationPayload, - type BrowserAnnotationPriority, - type BrowserGrabPayload, - type BrowserGrabRect, - type BrowserGrabScreenshot, - type BrowserPageAnnotation -} from '../../../../shared/browser-grab-types' -import { BROWSER_ANNOTATION_VIEWPORT_MESSAGE_PREFIX } from '../../../../shared/browser-annotation-viewport-bridge' -import { useGrabMode } from './useGrabMode' -import { formatGrabPayloadAsText } from './GrabConfirmationSheet' -import { formatBrowserAnnotationsAsMarkdown } from './browser-annotation-output' -import { isEditableKeyboardTarget } from './browser-keyboard' -import { getBrowserPagesForWorkspace } from './browser-pane-page-selection' -import BrowserAddressBar from './BrowserAddressBar' -import { BrowserImportHintButton } from './BrowserImportHintButton' -import { BrowserToolbarMenu } from './BrowserToolbarMenu' -import BrowserFind from './BrowserFind' -import { BrowserMobileDriverOverlay } from './BrowserMobileDriverOverlay' -import { getShortcutPlatform, useShortcutLabel } from '@/hooks/useShortcutLabel' -import { getRemoteBrowserFrameStyle } from './remote-browser-frame-style' -import { useRemoteBrowserStreamActivation } from './use-remote-browser-stream-activation' -import { - getRemoteBrowserKeyboardShortcut, - getRemoteBrowserKeypressKey -} from './remote-browser-keyboard' -import { - consumeBrowserFocusRequest, - ORCA_BROWSER_FOCUS_REQUEST_EVENT, - type BrowserFocusRequestDetail -} from './browser-focus' -import { - addBrowserPageZoomEventListener, - applyBrowserPageZoom, - browserPageZoomLevelToPercent, - DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - getBrowserPageZoomIndicatorState, - getExplicitBrowserPageZoomLevel, - normalizeBrowserPageZoomLevel, - rememberExplicitBrowserPageZoomLevel, - setBrowserPageZoomLevel, - type BrowserPageZoomDirection -} from './browser-page-zoom' -import { - isRemoteRuntimeFileOperation, - statRuntimePath, - type RuntimeFileOperationArgs -} from '@/runtime/runtime-file-client' -import { - callRuntimeRpc, - runtimeEnvironmentSupportsCapability, - type RuntimeClientTarget -} from '@/runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import type { - BrowserBackResult, - BrowserGotoResult, - BrowserReloadResult, - BrowserTabInfo -} from '../../../../shared/runtime-types' -import { - decodeBrowserScreencastFrame, - type BrowserScreencastFrameMetadata -} from '../../../../shared/browser-screencast-protocol' -import { formatByteCount, formatPermissionNotice, formatPopupNotice } from './browser-notices' -import { - getDriverForBrowserPage, - onBrowserDriverChange, - useBrowserMobileDrivenPageIds, - type BrowserDriverState -} from '@/lib/pane-manager/browser-mobile-driver-state' -import { shouldPollChromiumErrorPage } from './chromium-error-page-polling' -import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' -import { translate } from '@/i18n/i18n' -import { isBrowserPagePanePaintable } from './browser-page-paintability' -import { openWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-open' -import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode' -import { MarkupOverlay } from './markup/MarkupOverlay' -import { MarkupDrawButton } from './markup/MarkupDrawButton' -import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery' -import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' -import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton' -import { - browserFileUrlToAbsolutePath, - getShareableBrowserArtifactFile, - readBrowserHtmlArtifactRequest -} from './browser-artifact-upload' -import { - BROWSER_GUEST_RECOVERY_ERROR_CODE, - createBrowserPageGuestRecovery -} from './browser-page-guest-recovery' -import { subscribeBrowserSystemResume } from './browser-system-resume' -import { - canReconnectRemoteBrowserStream, - isRemoteBrowserStreamBusy, - REMOTE_BROWSER_STREAM_IDLE, - remoteBrowserStreamNotice, - type RemoteBrowserStreamStatus -} from './remote-browser-stream-status' -import { - type BrowserReloadTrigger, - reloadBrowserPageWebview, - resolveBrowserReloadButtonLabelKind, - resolveBrowserReloadIntent -} from './browser-reload-action' - -type BrowserTabPageState = Partial< - Pick< - BrowserPageState, - 'title' | 'loading' | 'faviconUrl' | 'canGoBack' | 'canGoForward' | 'loadError' - > -> - -type BrowserPageUrlSetter = ( - tabId: string, - url: string, - options?: { preserveLoadError?: boolean } -) => void - -type BrowserDownloadState = Omit & { - receivedBytes: number - status: 'downloading' | 'completed' | 'failed' | 'canceled' - savePath: string | null - error: string | null - progressState: BrowserDownloadProgressEvent['state'] - completedAt: number | null -} - -function formatBrowserDownloadProgress(download: BrowserDownloadState): string | null { - const received = formatByteCount(download.receivedBytes) - const total = formatByteCount(download.totalBytes) - if (received && total) { - return `${received} / ${total}` - } - return received ?? total -} - -type GrabIntent = 'copy' | 'annotate' - -type BrowserOverlayAnchor = { - x: number - y: number - below: boolean -} - -const BROWSER_ANNOTATION_INTENT_OPTIONS = [ - { - value: 'change', - get label() { - return translate('auto.components.browser.pane.BrowserPane.143204e423', 'Change') - }, - icon: PencilLine - }, - { - value: 'question', - get label() { - return translate('auto.components.browser.pane.BrowserPane.b5ba6085de', 'Question') - }, - icon: MessageCircleQuestionMark - } -] as const - -// Why: priority stays in the persisted annotation shape for backwards compat, though the UI no longer exposes urgency choices. -const DEFAULT_BROWSER_ANNOTATION_PRIORITY: BrowserAnnotationPriority = 'important' -const BROWSER_PAGE_ZOOM_FEEDBACK_MS = 1400 - -type BrowserOverlayViewport = { - scrollX: number - scrollY: number - version: number -} - -function decodeRemoteBrowserFrameUrl(url: string): Promise { - const image = new window.Image() - image.decoding = 'async' - image.src = url - if (typeof image.decode === 'function') { - return image.decode() - } - return new Promise((resolve, reject) => { - image.onload = () => resolve() - image.onerror = () => reject(new Error('Remote browser frame failed to decode.')) - }) -} - -type RemoteBrowserContextMenu = { - x: number - y: number - linkUrl: string | null - pageUrl: string - selectionText: string -} - -function getBrowserPageRuntimeEnvironmentId( - page: BrowserPageState, - inferredRuntimeEnvironmentId: string | null | undefined -): string | null { - if (page.browserRuntimeEnvironmentId !== undefined) { - return page.browserRuntimeEnvironmentId?.trim() || null - } - return inferredRuntimeEnvironmentId?.trim() || null -} - -type RemoteBrowserImagePoint = { - x: number - y: number -} - -type PendingRemoteBrowserWheel = { - target: RuntimeClientTarget & { kind: 'environment' } - pageId: string - operationToken: RemoteBrowserOperationToken - point: RemoteBrowserImagePoint - dx: number - dy: number -} - -const EMPTY_BROWSER_ANNOTATIONS: BrowserPageAnnotation[] = [] -const PENDING_ANNOTATION_CARD_HEIGHT = 330 -const WHEEL_DELTA_LINE = 1 -const WHEEL_DELTA_PAGE = 2 - -function createBrowserAnnotationId(): string { - return `browser-annotation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -} - -function createBrowserAnnotationPayload(payload: BrowserGrabPayload): BrowserAnnotationPayload { - return { - ...payload, - // Why: annotations are persisted; screenshot data is a transient copy payload that can be megabytes per selection. - screenshot: null - } -} - -function getBrowserOverlayAnchor( - payload: BrowserGrabPayload, - container: HTMLElement | null, - webview: Electron.WebviewTag | null, - viewport: BrowserOverlayViewport -): BrowserOverlayAnchor { - const containerRect = container?.getBoundingClientRect() - const webviewRect = webview?.getBoundingClientRect() - const rect = getLiveBrowserAnnotationRect(payload, viewport) - const offsetX = (webviewRect?.left ?? 0) - (containerRect?.left ?? 0) - const offsetY = (webviewRect?.top ?? 0) - (containerRect?.top ?? 0) - const elementBottom = offsetY + rect.y + rect.height - const elementTop = offsetY + rect.y - const containerWidth = containerRect?.width ?? 0 - const containerHeight = containerRect?.height ?? 0 - const below = elementBottom + PENDING_ANNOTATION_CARD_HEIGHT < containerHeight - return { - x: clampNumber(offsetX + rect.x + rect.width / 2, 12, Math.max(12, containerWidth - 12)), - y: clampNumber(below ? elementBottom : elementTop, 12, Math.max(12, containerHeight - 12)), - below - } -} - -function clampNumber(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max) -} - -function getLiveBrowserAnnotationRect( - payload: BrowserGrabPayload, - viewport: BrowserOverlayViewport -): BrowserGrabRect { - if (payload.target.isFixed) { - return payload.target.rectViewport - } - const scrollX = viewport.version === 0 ? payload.page.scrollX : viewport.scrollX - const scrollY = viewport.version === 0 ? payload.page.scrollY : viewport.scrollY - return { - ...payload.target.rectViewport, - x: payload.target.rectPage.x - scrollX, - y: payload.target.rectPage.y - scrollY - } -} - -function PendingBrowserAnnotationCard({ - payload, - anchor, - portalContainer, - onAdd, - onCancel -}: { - payload: BrowserGrabPayload - anchor: BrowserOverlayAnchor - portalContainer: HTMLElement | null - onAdd: (comment: string, intent: BrowserAnnotationIntent) => void - onCancel: () => void -}): React.JSX.Element { - const [comment, setComment] = useState('') - const [intent, setIntent] = useState('change') - const trimmed = comment.trim() - const submitModifierLabel = getScreenSubmitModifierLabel() - - return ( - { - if (!open) { - onCancel() - } - }} - > - - - - { - event.preventDefault() - onCancel() - }} - > -
-
- {payload.target.accessibility.accessibleName || - payload.target.textSnippet || - payload.target.tagName} -
-
- {payload.target.selector} -
-
- - '], + ['select', ''], + ['[contenteditable=""]', '
'], + ['[contenteditable="true"]', '
'], + ['.monaco-editor', '
'], + ['.diff-editor', '
'], + ['.rich-markdown-editor', '
'], + [ + '.rich-markdown-editor-shell', + '
' + ] + ])('returns true for a target inside %s', (_token, html) => { + expect(isEditableKeyboardTarget(targetInside(html))).toBe(true) + }) + + it('queries every editable host in one selector', () => { + const closest = vi.fn((_selector: string) => null) + isEditableKeyboardTarget({ isContentEditable: false, closest }) + + const selector = closest.mock.calls[0][0] + const tokens = selector.split(',').map((part) => part.trim()) + expect(tokens).toEqual([ + 'input', + 'textarea', + 'select', + '[contenteditable=""]', + '[contenteditable="true"]', + '.monaco-editor', + '.diff-editor', + '.rich-markdown-editor', + '.rich-markdown-editor-shell' + ]) + }) + + it('falls back to isContentEditable when no host selector matches', () => { + expect(isEditableKeyboardTarget({ isContentEditable: true, closest: () => null })).toBe(true) + }) + + it('returns false for non-editable elements', () => { + expect(isEditableKeyboardTarget(targetInside('
'))).toBe(false) + expect(isEditableKeyboardTarget({ isContentEditable: false, closest: () => null })).toBe(false) + }) + + it('returns false for a null target', () => { + expect(isEditableKeyboardTarget(null)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-keyboard.ts b/src/renderer/src/components/browser-pane/host-guest/browser-keyboard.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-keyboard.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-keyboard.ts diff --git a/src/renderer/src/components/browser-pane/browser-page-guest-recovery.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-page-guest-recovery.test.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts diff --git a/src/renderer/src/components/browser-pane/browser-page-guest-recovery.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-page-guest-recovery.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.ts diff --git a/src/renderer/src/components/browser-pane/browser-page-paintability.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-page-paintability.test.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts diff --git a/src/renderer/src/components/browser-pane/browser-page-paintability.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-page-paintability.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.ts diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts similarity index 85% rename from src/renderer/src/components/browser-pane/browser-page-viewport.test.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts index d6def5c80e5..0cf2ca9ab62 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts @@ -8,6 +8,7 @@ import { parkBrowserPageViewport, registerBrowserOverlaySlotViewport, removeBrowserPageViewport, + subscribeBrowserOverlaySlotViewport, syncBrowserPageChromeInset } from './browser-page-viewport' @@ -144,6 +145,40 @@ describe('syncBrowserPageChromeInset', () => { }) }) +describe('subscribeBrowserOverlaySlotViewport', () => { + it('keeps notifying a mounted subscriber across an unregister/re-register cycle', () => { + const seen: (HTMLDivElement | null)[] = [] + const unsubscribe = subscribeBrowserOverlaySlotViewport('workspace-1', () => { + seen.push(getBrowserOverlaySlotViewport('workspace-1')) + }) + + const first = mountSlotViewport('workspace-1') + first.remove() + registerBrowserOverlaySlotViewport('workspace-1', null) + const second = mountSlotViewport('workspace-1') + + expect(seen).toEqual([first, null, second]) + unsubscribe() + }) + + it('does not let a repeated stale unsubscribe drop a newer subscriber set', () => { + const stale = subscribeBrowserOverlaySlotViewport('workspace-1', () => {}) + stale() + + let notified = 0 + const active = subscribeBrowserOverlaySlotViewport('workspace-1', () => { + notified += 1 + }) + // StrictMode/double-cleanup: the emptied first Set must not evict the replacement. + stale() + + mountSlotViewport('workspace-1') + + expect(notified).toBe(1) + active() + }) +}) + describe('applyBrowserPageViewportLayout', () => { it('shows the active page and hides parked pages', () => { mountSlotViewport('workspace-1') diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts similarity index 91% rename from src/renderer/src/components/browser-pane/browser-page-viewport.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts index e4a2441c755..6b7246f5eef 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts @@ -37,7 +37,9 @@ export function registerBrowserOverlaySlotViewport( return } slotViewportRoots.delete(workspaceTabId) - slotRootListeners.delete(workspaceTabId) + // Why: subscribers outlive the root they watch — dropping them here would leave a + // remounted slot with live components that never hear about the new root. + notifySlotRootListeners(workspaceTabId) } export function getBrowserOverlaySlotViewport(workspaceTabId: string): HTMLDivElement | null { @@ -53,10 +55,13 @@ export function subscribeBrowserOverlaySlotViewport( listeners = new Set() slotRootListeners.set(workspaceTabId, listeners) } - listeners.add(listener) + const ownedListeners = listeners + ownedListeners.add(listener) return () => { - listeners?.delete(listener) - if (listeners?.size === 0) { + ownedListeners.delete(listener) + // Why: only drop the map entry this unsubscribe still owns; a replacement Set + // may already be registered for the same tab. + if (ownedListeners.size === 0 && slotRootListeners.get(workspaceTabId) === ownedListeners) { slotRootListeners.delete(workspaceTabId) } } diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts new file mode 100644 index 00000000000..bc767017b70 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts @@ -0,0 +1,261 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import { redactKagiSessionToken } from '../../../../../shared/browser-url' +import { + browserViewportPresetToOverride, + getBrowserViewportPreset +} from '../../../../../shared/browser-viewport-presets' +import type { + BrowserLoadError, + BrowserViewportPresetId +} from '../../../../../shared/browser-workspace-types' +import { translate } from '@/i18n/i18n' +import { + BROWSER_GUEST_RECOVERY_ERROR_CODE, + createBrowserPageGuestRecovery, + type BrowserPageGuestRecovery +} from './browser-page-guest-recovery' +import { browserPageZoomLevelToPercent, setBrowserPageZoomLevel } from './browser-page-zoom' +import { registeredWebContentsIds, replacePersistentWebview } from './webview-registry' +import { browserPageExists } from '../describe-page/browser-page-load-error' +import type { + BrowserPageRecoveryNavigationValidation, + BrowserTabPageState +} from '../describe-page/browser-page-types' + +export type BrowserPageWebviewGuestSessionArgs = { + webview: Electron.WebviewTag + browserTabId: string + workspaceId: string + worktreeId: string + sessionProfileId: string | null + webviewRef: MutableRefObject + isPaintableRef: MutableRefObject + guestRecoveryPendingRef: MutableRefObject + browserTabUrlRef: MutableRefObject + addressBarValueRef: MutableRefObject + activeLoadFailureRef: MutableRefObject + recoveryNavigationValidationRef: MutableRefObject + keepAddressBarFocusRef: MutableRefObject + paneZoomLevelRef: MutableRefObject + viewportPresetIdRef: MutableRefObject + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> + setGuestRecoveryGeneration: Dispatch> + setBrowserZoomPercent: Dispatch> + focusAddressBarNow: () => boolean + syncNavigationState: (webview: Electron.WebviewTag) => void + syncBrowserAnnotationViewportBridge: () => void +} + +export type BrowserPageWebviewGuestSession = { + guestRecovery: BrowserPageGuestRecovery + handleDidAttach: () => void + handleDomReady: () => void + handleGuestDestroyed: () => void +} + +export function createBrowserPageWebviewGuestSession({ + webview, + browserTabId, + workspaceId, + worktreeId, + sessionProfileId, + webviewRef, + isPaintableRef, + guestRecoveryPendingRef, + browserTabUrlRef, + addressBarValueRef, + activeLoadFailureRef, + recoveryNavigationValidationRef, + keepAddressBarFocusRef, + paneZoomLevelRef, + viewportPresetIdRef, + onUpdatePageStateRef, + setGuestRecoveryGeneration, + setBrowserZoomPercent, + focusAddressBarNow, + syncNavigationState, + syncBrowserAnnotationViewportBridge +}: BrowserPageWebviewGuestSessionArgs): BrowserPageWebviewGuestSession { + let registrationInFlight: { + webContentsId: number + promise: Promise + } | null = null + const registerGuest = (): Promise => { + let webContentsId: number + try { + webContentsId = webview.getWebContentsId() + } catch { + return Promise.resolve(null) + } + if (registrationInFlight?.webContentsId === webContentsId) { + return registrationInFlight.promise + } + const promise = window.api.browser + .registerGuest({ + browserPageId: browserTabId, + workspaceId, + worktreeId, + sessionProfileId, + webContentsId + }) + .then((registered) => { + if (registered) { + registeredWebContentsIds.set(browserTabId, webContentsId) + return true + } + return null + }) + // Why: registration rejection can be an attach-policy race; only validation of an identified guest proves loss. + .catch(() => null) + .finally(() => { + if (registrationInFlight?.promise === promise) { + registrationInFlight = null + } + }) + registrationInFlight = { webContentsId, promise } + return promise + } + + const clearGuestRecoveryError = (): void => { + if (activeLoadFailureRef.current?.code !== BROWSER_GUEST_RECOVERY_ERROR_CODE) { + return + } + activeLoadFailureRef.current = null + onUpdatePageStateRef.current(browserTabId, { loading: false, loadError: null }) + } + + const guestRecovery = createBrowserPageGuestRecovery({ + webview, + browserPageExists: () => browserPageExists(browserTabId), + shouldValidate: () => isPaintableRef.current, + isCurrentWebview: () => webviewRef.current === webview, + isPending: () => guestRecoveryPendingRef.current, + setPending: (pending) => { + guestRecoveryPendingRef.current = pending + }, + validateRegistration: async () => { + let webContentsId: number + try { + webContentsId = webview.getWebContentsId() + } catch { + // Why: a reused webview can remount before dom-ready; only an identified guest can be declared missing. + return null + } + if (registeredWebContentsIds.get(browserTabId) !== webContentsId) { + return registerGuest() + } + const registered = await window.api.browser.isGuestRegistered({ + browserPageId: browserTabId, + webContentsId + }) + if (registered) { + return true + } + return window.api.browser.repairGuestRegistration({ + browserPageId: browserTabId, + workspaceId, + worktreeId, + sessionProfileId, + webContentsId + }) + }, + replaceGuest: () => replacePersistentWebview(browserTabId), + onReplacementReady: () => setGuestRecoveryGeneration((generation) => generation + 1), + onRecoveryFailed: () => { + const loadError = { + code: BROWSER_GUEST_RECOVERY_ERROR_CODE, + description: translate( + 'browser.guestRecovery.failed', + 'The browser page stopped unexpectedly. Retry to restore it.' + ), + validatedUrl: redactKagiSessionToken( + browserTabUrlRef.current || addressBarValueRef.current || 'about:blank' + ) + } + activeLoadFailureRef.current = loadError + onUpdatePageStateRef.current(browserTabId, { loading: false, loadError }) + }, + onRecoverySucceeded: clearGuestRecoveryError + }) + + const handleDidAttach = (): void => { + // Why: register at attach since cert failures can precede dom-ready; the dom-ready path stays an idempotent fallback. + void registerGuest().then((registered) => { + if (registered === true) { + guestRecovery.confirmRegistration() + } + syncBrowserAnnotationViewportBridge() + }) + } + + const handleDomReady = (): void => { + const validateRecoveryAfterNavigation = + recoveryNavigationValidationRef.current?.committed === true + if (validateRecoveryAfterNavigation) { + recoveryNavigationValidationRef.current = null + } + let liveWebContentsId: number | null = null + try { + liveWebContentsId = webview.getWebContentsId() + } catch { + // Why: the guest can detach between dom-ready and registration. + } + const queuedAnnotationViewportBridgeSync = + liveWebContentsId === null || registeredWebContentsIds.get(browserTabId) !== liveWebContentsId + if (queuedAnnotationViewportBridgeSync) { + void registerGuest().then((registered) => { + const completedRecovery = guestRecovery.finish() + if (registered === true) { + guestRecovery.confirmRegistration() + clearGuestRecoveryError() + } + if (registered === null || completedRecovery || validateRecoveryAfterNavigation) { + guestRecovery.validateAfterResume() + } + syncBrowserAnnotationViewportBridge() + }) + } else { + const completedRecovery = guestRecovery.finish() + if (completedRecovery || validateRecoveryAfterNavigation) { + guestRecovery.validateAfterResume() + } + } + syncNavigationState(webview) + if (keepAddressBarFocusRef.current) { + focusAddressBarNow() + } + if (!queuedAnnotationViewportBridgeSync) { + syncBrowserAnnotationViewportBridge() + } + // Why: Chromium restores per-origin zoom on reload/navigation, so reassert THIS pane's level after + // every guest load. Uses the pane-local level, not the shared setting, so reloading one tab never + // adopts a zoom the user applied to a different tab. + const appliedLevel = setBrowserPageZoomLevel(webview, paneZoomLevelRef.current) + if (appliedLevel !== null) { + setBrowserZoomPercent(browserPageZoomLevelToPercent(appliedLevel)) + } + // Why: CDP viewport overrides are scoped to the debugger session and don't survive cross-origin nav, so reapply (idempotently) on dom-ready. + const presetId = viewportPresetIdRef.current + const preset = getBrowserViewportPreset(presetId) + // Why: reapply even null so CDP matches store state; setDeviceMetricsOverride persists across same-origin nav and would leave a stale viewport. + void window.api.browser.setViewportOverride({ + browserPageId: browserTabId, + override: preset ? browserViewportPresetToOverride(preset) : null + }) + } + + const handleGuestDestroyed = (): void => { + // Why: a guest can be destroyed without render-process-gone (detach/reattach race, + // guest-side close). Skip intentional teardown, where the element already left the DOM. + if (webview.isConnected) { + guestRecovery.recoverRenderer() + } + } + + return { + guestRecovery, + handleDidAttach, + handleDomReady, + handleGuestDestroyed + } +} diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts new file mode 100644 index 00000000000..77d263f581a --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts @@ -0,0 +1,197 @@ +import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react' +import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../../shared/browser-url' +import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { translate } from '@/i18n/i18n' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { rememberLiveBrowserUrl } from '../describe-page/live-browser-url-registry' +import type { BrowserOverlayViewport } from '../describe-page/browser-annotation-geometry' +import { buildLoadError } from '../describe-page/browser-page-load-error' +import { + getBrowserDisplayTitle, + isChromiumErrorPage, + toDisplayUrl +} from '../describe-page/browser-page-url-display' +import type { + BrowserPageFailLoadEvent, + BrowserPageRecoveryNavigationValidation, + BrowserPageUrlSetter, + BrowserTabPageState +} from '../describe-page/browser-page-types' + +export type BrowserPageWebviewLoadingHandlersArgs = { + webview: Electron.WebviewTag + browserTabId: string + faviconUrlRef: MutableRefObject + browserTabUrlRef: MutableRefObject + addressBarValueRef: MutableRefObject + addressBarInputRef: RefObject + activeLoadFailureRef: MutableRefObject + lastKnownWebviewUrlRef: MutableRefObject + trackNextLoadingEventRef: MutableRefObject + keepAddressBarFocusRef: MutableRefObject + recoveryNavigationValidationRef: MutableRefObject + clearBrowserPageAnnotationsRef: MutableRefObject<(pageId: string) => void> + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> + onSetUrlRef: MutableRefObject + setPendingAnnotationPayload: Dispatch> + setBrowserOverlayViewport: Dispatch> + setAddressBarValue: Dispatch> + focusAddressBarNow: () => boolean +} + +export type BrowserPageWebviewLoadingHandlers = { + handleDidStartLoading: () => void + handleDidStopLoading: () => void + handleFailLoad: (event: BrowserPageFailLoadEvent) => void +} + +export function createBrowserPageWebviewLoadingHandlers({ + webview, + browserTabId, + faviconUrlRef, + browserTabUrlRef, + addressBarValueRef, + addressBarInputRef, + activeLoadFailureRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + keepAddressBarFocusRef, + recoveryNavigationValidationRef, + clearBrowserPageAnnotationsRef, + onUpdatePageStateRef, + onSetUrlRef, + setPendingAnnotationPayload, + setBrowserOverlayViewport, + setAddressBarValue, + focusAddressBarNow +}: BrowserPageWebviewLoadingHandlersArgs): BrowserPageWebviewLoadingHandlers { + const handleDidStartLoading = (): void => { + // Why: a reload replaces the document without changing the URL, invalidating captured element rects like a navigation does. + clearBrowserPageAnnotationsRef.current(browserTabId) + setPendingAnnotationPayload(null) + setBrowserOverlayViewport({ scrollX: 0, scrollY: 0, version: 0 }) + if (!trackNextLoadingEventRef.current) { + return + } + faviconUrlRef.current = null + onUpdatePageStateRef.current(browserTabId, { + loading: true, + faviconUrl: null + }) + } + + const handleDidStopLoading = (): void => { + const currentUrl = webview.getURL() || webview.src || 'about:blank' + const browserModelUrl = redactKagiSessionToken(currentUrl) + const activeLoadFailure = activeLoadFailureRef.current + if (isChromiumErrorPage(currentUrl)) { + trackNextLoadingEventRef.current = false + const synthesizedFailure = { + code: -1, + description: translate( + 'auto.components.browser.pane.BrowserPane.e48569ac6d', + 'This site could not be reached.' + ), + validatedUrl: redactKagiSessionToken( + browserTabUrlRef.current || addressBarValueRef.current || 'about:blank' + ) + } + activeLoadFailureRef.current = synthesizedFailure + onUpdatePageStateRef.current(browserTabId, { + loading: false, + loadError: synthesizedFailure + }) + return + } + if (activeLoadFailure?.code === BROWSER_GUEST_RECOVERY_ERROR_CODE) { + trackNextLoadingEventRef.current = false + onUpdatePageStateRef.current(browserTabId, { + loading: false, + title: getBrowserDisplayTitle(webview.getTitle(), browserModelUrl), + faviconUrl: faviconUrlRef.current, + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + loadError: activeLoadFailure + }) + return + } else if (activeLoadFailure) { + const normalizedAttemptedUrl = + normalizeBrowserNavigationUrl(activeLoadFailure.validatedUrl) ?? + activeLoadFailure.validatedUrl + const normalizedCurrentUrl = normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl + if (normalizedAttemptedUrl === normalizedCurrentUrl) { + trackNextLoadingEventRef.current = false + // Why: some failures still emit did-stop-loading on the original URL; keep loadError so the known-failed load isn't cleared to a blank surface. + onUpdatePageStateRef.current(browserTabId, { + loading: false, + title: getBrowserDisplayTitle(webview.getTitle(), browserModelUrl), + faviconUrl: faviconUrlRef.current, + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + loadError: activeLoadFailure + }) + return + } + } + trackNextLoadingEventRef.current = false + activeLoadFailureRef.current = null + lastKnownWebviewUrlRef.current = + normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl + rememberLiveBrowserUrl(browserTabId, browserModelUrl) + // Why: don't overwrite in-progress typing (see the browserTab.url sync effect above). + if (document.activeElement !== addressBarInputRef.current) { + setAddressBarValue(toDisplayUrl(browserModelUrl)) + } + onSetUrlRef.current(browserTabId, browserModelUrl) + if (keepAddressBarFocusRef.current && currentUrl === ORCA_BROWSER_BLANK_URL) { + focusAddressBarNow() + } else { + keepAddressBarFocusRef.current = false + } + onUpdatePageStateRef.current(browserTabId, { + loading: false, + title: getBrowserDisplayTitle(webview.getTitle(), browserModelUrl), + faviconUrl: faviconUrlRef.current, + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + loadError: null + }) + } + + const handleFailLoad = (event: { + errorCode?: number + errorDescription?: string + validatedURL?: string + isMainFrame?: boolean + }): void => { + if (event.isMainFrame === false) { + return + } + if (event.errorCode === -3) { + // Why: Chromium reports redirect/cancel races as ERR_ABORTED (-3) even when the replacement navigation succeeds; ignore to avoid a false failure. + return + } + trackNextLoadingEventRef.current = false + const pendingRecoveryNavigation = recoveryNavigationValidationRef.current + if (pendingRecoveryNavigation?.started) { + recoveryNavigationValidationRef.current = null + } + const loadError = buildLoadError(event) + activeLoadFailureRef.current = loadError + onUpdatePageStateRef.current(browserTabId, { + loading: false, + loadError + }) + } + + return { + handleDidStartLoading, + handleDidStopLoading, + handleFailLoad + } +} diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts new file mode 100644 index 00000000000..e0836ed5b9a --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts @@ -0,0 +1,182 @@ +import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react' +import { BROWSER_ANNOTATION_VIEWPORT_MESSAGE_PREFIX } from '../../../../../shared/browser-annotation-viewport-bridge' +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../../shared/browser-url' +import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { rememberLiveBrowserUrl } from '../describe-page/live-browser-url-registry' +import type { BrowserOverlayViewport } from '../describe-page/browser-annotation-geometry' +import { + getBrowserDisplayTitle, + isChromiumErrorPage, + toDisplayUrl +} from '../describe-page/browser-page-url-display' +import type { + BrowserPageNavigateEvent, + BrowserPageRecoveryNavigationValidation, + BrowserPageUrlSetter, + BrowserTabPageState +} from '../describe-page/browser-page-types' + +export type BrowserPageWebviewNavigationHandlersArgs = { + webview: Electron.WebviewTag + browserTabId: string + browserTabUrl: string + recoveryNavigationValidationRef: MutableRefObject + activeLoadFailureRef: MutableRefObject + lastKnownWebviewUrlRef: MutableRefObject + addressBarInputRef: RefObject + onSetUrlRef: MutableRefObject + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> + addBrowserHistoryEntryRef: MutableRefObject<(url: string, title: string) => void> + faviconUrlRef: MutableRefObject + setAddressBarValue: Dispatch> + annotationViewportBridgeTokenRef: MutableRefObject + setBrowserOverlayViewport: Dispatch> +} + +export type BrowserPageWebviewNavigationHandlers = { + handleDidStartNavigation: (event: Electron.DidStartNavigationEvent) => void + handleFullDidNavigate: (event: BrowserPageNavigateEvent) => void + handleDidNavigateInPage: (event: BrowserPageNavigateEvent) => void + handleTitleUpdate: (event: { title?: string }) => void + handleFaviconUpdate: (event: { favicons?: string[] }) => void + handleAnnotationViewportMessage: (event: { message?: string }) => void +} + +export function createBrowserPageWebviewNavigationHandlers({ + webview, + browserTabId, + browserTabUrl, + recoveryNavigationValidationRef, + activeLoadFailureRef, + lastKnownWebviewUrlRef, + addressBarInputRef, + onSetUrlRef, + onUpdatePageStateRef, + addBrowserHistoryEntryRef, + faviconUrlRef, + setAddressBarValue, + annotationViewportBridgeTokenRef, + setBrowserOverlayViewport +}: BrowserPageWebviewNavigationHandlersArgs): BrowserPageWebviewNavigationHandlers { + const handleDidStartNavigation = (event: Electron.DidStartNavigationEvent): void => { + if (!event.isMainFrame || event.isInPlace || !event.url) { + return + } + const pendingRecoveryNavigation = recoveryNavigationValidationRef.current + const browserStartedUrl = redactKagiSessionToken(event.url) + const startedUrl = normalizeBrowserNavigationUrl(browserStartedUrl) ?? browserStartedUrl + if (pendingRecoveryNavigation?.targetUrl === startedUrl) { + pendingRecoveryNavigation.started = true + } + } + + const handleDidNavigate = ( + event: { url?: string; isMainFrame?: boolean }, + persistUrl = true, + preserveLoadError = false + ): void => { + if (event.isMainFrame === false) { + return + } + const currentUrl = event.url ?? webview.getURL() ?? webview.src ?? 'about:blank' + if (isChromiumErrorPage(currentUrl)) { + return + } + const browserModelUrl = redactKagiSessionToken(currentUrl) + const normalizedBrowserModelUrl = + normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl + lastKnownWebviewUrlRef.current = normalizedBrowserModelUrl + rememberLiveBrowserUrl(browserTabId, browserModelUrl) + // Why: don't overwrite in-progress typing (see above). + if (document.activeElement !== addressBarInputRef.current) { + setAddressBarValue(toDisplayUrl(browserModelUrl)) + } + if (persistUrl) { + onSetUrlRef.current(browserTabId, browserModelUrl, { preserveLoadError }) + } + onUpdatePageStateRef.current(browserTabId, { + title: webview.getTitle() || browserModelUrl, + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward() + }) + } + + const handleFullDidNavigate = (event: { url?: string; isMainFrame?: boolean }): void => { + const pendingRecoveryNavigation = recoveryNavigationValidationRef.current + if (event.isMainFrame !== false && pendingRecoveryNavigation?.started) { + pendingRecoveryNavigation.committed = true + } + const preserveRecoveryError = + activeLoadFailureRef.current?.code === BROWSER_GUEST_RECOVERY_ERROR_CODE + handleDidNavigate(event, true, preserveRecoveryError) + } + + const handleDidNavigateInPage = (event: { url?: string; isMainFrame?: boolean }): void => { + const preserveRecoveryError = + activeLoadFailureRef.current?.code === BROWSER_GUEST_RECOVERY_ERROR_CODE + handleDidNavigate(event, !preserveRecoveryError) + } + + const handleTitleUpdate = (event: { title?: string }): void => { + try { + const currentUrl = webview.getURL() || browserTabUrl + const browserModelUrl = redactKagiSessionToken(currentUrl) + const title = getBrowserDisplayTitle(event.title, browserModelUrl) + onUpdatePageStateRef.current(browserTabId, { title }) + addBrowserHistoryEntryRef.current(browserModelUrl, title) + } catch { + // Why: title-updated can fire before dom-ready, making getURL() throw. + } + } + + const handleFaviconUpdate = (event: { favicons?: string[] }): void => { + const faviconUrl = event.favicons?.[0] ?? null + faviconUrlRef.current = + faviconUrl && + (faviconUrl.startsWith('https://') || + faviconUrl.startsWith('http://') || + faviconUrl.startsWith('data:image/')) + ? faviconUrl + : null + onUpdatePageStateRef.current(browserTabId, { faviconUrl: faviconUrlRef.current }) + } + + const handleAnnotationViewportMessage = (event: { message?: string }): void => { + const message = typeof event.message === 'string' ? event.message : '' + const prefix = `${BROWSER_ANNOTATION_VIEWPORT_MESSAGE_PREFIX}${annotationViewportBridgeTokenRef.current}:` + if (!message.startsWith(prefix)) { + return + } + try { + const next = JSON.parse(message.slice(prefix.length)) as { + scrollX?: unknown + scrollY?: unknown + } + const scrollX = + typeof next.scrollX === 'number' && Number.isFinite(next.scrollX) ? next.scrollX : 0 + const scrollY = + typeof next.scrollY === 'number' && Number.isFinite(next.scrollY) ? next.scrollY : 0 + setBrowserOverlayViewport((current) => { + if (current.scrollX === scrollX && current.scrollY === scrollY) { + return current.version === 0 ? { ...current, version: 1 } : current + } + return { scrollX, scrollY, version: current.version + 1 } + }) + } catch { + // Ignore unrelated or malformed guest console output. + } + } + + return { + handleDidStartNavigation, + handleFullDidNavigate, + handleDidNavigateInPage, + handleTitleUpdate, + handleFaviconUpdate, + handleAnnotationViewportMessage + } +} diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts new file mode 100644 index 00000000000..e6c9cb0a385 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { setBrowserPageWebviewInputLock } from './browser-page-webview' + +describe('setBrowserPageWebviewInputLock', () => { + it('updates an existing webview when browser control changes hands', () => { + const webview = document.createElement('webview') as Electron.WebviewTag + + setBrowserPageWebviewInputLock(webview, true) + expect(webview.style.pointerEvents).toBe('none') + + setBrowserPageWebviewInputLock(webview, false) + expect(webview.style.pointerEvents).toBe('auto') + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts similarity index 90% rename from src/renderer/src/components/browser-pane/browser-page-webview.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts index f5ebdc19f3c..30e1cc18442 100644 --- a/src/renderer/src/components/browser-pane/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts @@ -1,4 +1,4 @@ -import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences' import { destroyPersistentWebview, registerPersistentWebview, @@ -6,6 +6,13 @@ import { webviewRegistry } from './webview-registry' +export function setBrowserPageWebviewInputLock( + webview: Electron.WebviewTag, + inputLocked: boolean +): void { + webview.style.pointerEvents = inputLocked ? 'none' : 'auto' +} + export function ensureBrowserPageWebview({ browserTabId, container, @@ -43,7 +50,7 @@ export function ensureBrowserPageWebview({ activeContainer = refreshedContainer } if (webview) { - webview.style.pointerEvents = inputLocked ? 'none' : 'auto' + setBrowserPageWebviewInputLock(webview, inputLocked) return { container: activeContainer, created, webview } } @@ -58,7 +65,7 @@ export function ensureBrowserPageWebview({ webview.style.width = '100%' webview.style.height = '100%' webview.style.border = 'none' - webview.style.pointerEvents = inputLocked ? 'none' : 'auto' + setBrowserPageWebviewInputLock(webview, inputLocked) // Why: some pages never paint a background, and a white viewport matches // normal browser behavior instead of leaking Orca chrome through the guest. webview.style.background = '#ffffff' diff --git a/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-page-zoom.test.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts diff --git a/src/renderer/src/components/browser-pane/browser-page-zoom.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.ts similarity index 97% rename from src/renderer/src/components/browser-pane/browser-page-zoom.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.ts index 2a5233c5cf1..63eba691076 100644 --- a/src/renderer/src/components/browser-pane/browser-page-zoom.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.ts @@ -3,7 +3,7 @@ import { nextBrowserPageZoomLevel, normalizeBrowserPageZoomLevel, type BrowserPageZoomDirection -} from '../../../../shared/browser-page-zoom' +} from '../../../../../shared/browser-page-zoom' export { BROWSER_PAGE_ZOOM_LEVELS, @@ -12,7 +12,7 @@ export { nextBrowserPageZoomLevel, normalizeBrowserPageZoomLevel, type BrowserPageZoomDirection -} from '../../../../shared/browser-page-zoom' +} from '../../../../../shared/browser-page-zoom' export const ORCA_BROWSER_PAGE_ZOOM_EVENT = 'orca:browser-page-zoom' diff --git a/src/renderer/src/components/browser-pane/browser-system-resume.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-system-resume.test.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts diff --git a/src/renderer/src/components/browser-pane/browser-system-resume.ts b/src/renderer/src/components/browser-pane/host-guest/browser-system-resume.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-system-resume.ts rename to src/renderer/src/components/browser-pane/host-guest/browser-system-resume.ts diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts new file mode 100644 index 00000000000..22e65759376 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts @@ -0,0 +1,264 @@ +import { useEffect, type MutableRefObject } from 'react' +import { getShortcutPlatform } from '@/hooks/useShortcutLabel' +import { useAppStore } from '@/store' +import { keybindingMatchesAction } from '../../../../../shared/keybindings' +import { isEditableKeyboardTarget } from './browser-keyboard' +import { + addBrowserPageZoomEventListener, + applyBrowserPageZoom, + rememberExplicitBrowserPageZoomLevel, + type BrowserPageZoomDirection +} from './browser-page-zoom' +import type { GrabIntent } from '../describe-page/browser-page-types' + +export function useBrowserPageKeyboardShortcuts({ + browserTabId, + isActive, + isActiveRef, + markupIsActive, + webviewRef, + paneZoomLevelRef, + setBrowserDefaultZoomLevel, + showBrowserZoomFeedback, + reloadWebviewOrRecoverGuest, + startGrabIntent, + focusAddressBarNow, + handleGrabActionShortcut, + grabIsInteractive +}: { + browserTabId: string + isActive: boolean + isActiveRef: MutableRefObject + markupIsActive: boolean + webviewRef: MutableRefObject + paneZoomLevelRef: MutableRefObject + setBrowserDefaultZoomLevel: (level: number) => void + showBrowserZoomFeedback: (level: number) => void + reloadWebviewOrRecoverGuest: (ignoreCache: boolean) => void + startGrabIntent: (intent: GrabIntent) => void + focusAddressBarNow: () => boolean + handleGrabActionShortcut: (key: 'c' | 's') => void + grabIsInteractive: boolean +}): void { + const keybindings = useAppStore((state) => state.keybindings) + + // Browser history shortcuts (renderer path: focus on browser chrome) + // Why: macOS can't deliver Logitech side-buttons to Electron; Logi Options+ remaps them to history chords, handled here when chrome is focused. + useEffect(() => { + if (!isActive) { + return + } + const shortcutPlatform = getShortcutPlatform() + const handleKeyDown = (e: KeyboardEvent): void => { + const direction = keybindingMatchesAction('browser.back', e, shortcutPlatform, keybindings) + ? 'back' + : keybindingMatchesAction('browser.forward', e, shortcutPlatform, keybindings) + ? 'forward' + : null + if (direction === null) { + return + } + e.preventDefault() + e.stopPropagation() + // Why: Logitech Options+ side-button remaps arrive as these chords on macOS; route through the same nav path as the toolbar. + if (direction === 'back') { + webviewRef.current?.goBack() + } else { + webviewRef.current?.goForward() + } + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [isActive, keybindings, webviewRef]) + + // Browser history shortcuts (IPC path: focus inside webview guest) + // Why: a focused webview is a separate WebContents, so main forwards the chords back here. + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onBrowserHistoryNavigate((direction) => { + // Why: Logitech Options+ side-button remaps arrive as these chords on macOS; route through the same nav path as the toolbar. + if (direction === 'back') { + webviewRef.current?.goBack() + } else { + webviewRef.current?.goForward() + } + }) + }, [isActive, webviewRef]) + + // Cmd/Ctrl+R — reload (renderer path: focus on browser chrome, not in guest) + // Why: guest shortcut forwarding never fires when focus is on browser chrome, so handle the chord directly here. + useEffect(() => { + if (!isActive) { + return + } + const shortcutPlatform = getShortcutPlatform() + const handleKeyDown = (e: KeyboardEvent): void => { + const isHardReload = keybindingMatchesAction( + 'browser.hardReload', + e, + shortcutPlatform, + keybindings + ) + const isReload = keybindingMatchesAction('browser.reload', e, shortcutPlatform, keybindings) + if (!isHardReload && !isReload) { + return + } + if (isEditableKeyboardTarget(e.target)) { + return + } + e.preventDefault() + e.stopPropagation() + reloadWebviewOrRecoverGuest(isHardReload) + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [isActive, keybindings, reloadWebviewOrRecoverGuest]) + + // Cmd/Ctrl+R — reload (IPC path: focus inside webview guest) + // Why: a focused guest is a separate Chromium process, so main forwards the chord back here. + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onReloadBrowserPage(() => { + reloadWebviewOrRecoverGuest(false) + }) + }, [isActive, reloadWebviewOrRecoverGuest]) + + useEffect(() => { + if (!isActive) { + return + } + const applyActivePageZoom = (direction: BrowserPageZoomDirection): void => { + if (!isActiveRef.current) { + return + } + // Why: reset targets 100% like Chromium; the configured default is a new-tab seed, not a reset target. + const nextLevel = applyBrowserPageZoom(webviewRef.current, direction) + if (nextLevel !== null) { + paneZoomLevelRef.current = nextLevel + rememberExplicitBrowserPageZoomLevel(browserTabId, nextLevel) + setBrowserDefaultZoomLevel(nextLevel) + showBrowserZoomFeedback(nextLevel) + } + } + const removeGuestListener = window.api.ui.onZoomBrowserPage(applyActivePageZoom) + const removeLocalListener = addBrowserPageZoomEventListener((detail) => { + if (detail.browserPageId !== browserTabId) { + return + } + applyActivePageZoom(detail.direction) + }) + return () => { + removeGuestListener() + removeLocalListener() + } + }, [ + browserTabId, + isActive, + isActiveRef, + paneZoomLevelRef, + setBrowserDefaultZoomLevel, + showBrowserZoomFeedback, + webviewRef + ]) + + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onHardReloadBrowserPage(() => { + reloadWebviewOrRecoverGuest(true) + }) + }, [isActive, reloadWebviewOrRecoverGuest]) + + // Why: Cmd+C is repurposed as the grab-mode gesture; native text copy in the guest is handled by Chromium and never reaches here. + useEffect(() => { + // Why: gate on isActive so only the active pane's global keydown listener toggles grab mode. + if (!isActive) { + return + } + const shortcutPlatform = getShortcutPlatform() + const handleKeyDown = (e: KeyboardEvent): void => { + // Why: don't intercept in editable targets so native Cmd+C still copies in inputs/contentEditable. + if (isEditableKeyboardTarget(e.target)) { + return + } + // Why: don't start the in-guest picker behind an open markup overlay (matches the disabled toolbar buttons). + if ( + !markupIsActive && + keybindingMatchesAction('browser.grabElement', e, shortcutPlatform, keybindings) + ) { + e.preventDefault() + startGrabIntent('copy') + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isActive, keybindings, markupIsActive, startGrabIntent]) + + useEffect(() => { + if (!isActive) { + return + } + const shortcutPlatform = getShortcutPlatform() + const handleKeyDown = (e: KeyboardEvent): void => { + if (!keybindingMatchesAction('browser.focusAddressBar', e, shortcutPlatform, keybindings)) { + return + } + // Why: capture Cmd/Ctrl+L before the workspace or an embedded editor can claim the same chord. + e.preventDefault() + e.stopPropagation() + focusAddressBarNow() + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [focusAddressBarNow, isActive, keybindings]) + + // Why: a focused guest gets Cmd/Ctrl+C inside Chromium; main forwards it back only when the page wouldn't use it for native copy. + useEffect(() => { + return window.api.browser.onGrabModeToggle((tabId) => { + if (tabId === browserTabId) { + startGrabIntent('copy') + } + }) + }, [browserTabId, startGrabIntent]) + + useEffect(() => { + if (!grabIsInteractive) { + return + } + const handleKeyDown = (e: KeyboardEvent): void => { + if (isEditableKeyboardTarget(e.target)) { + return + } + // Ignore if modifier keys are held — user may be doing Cmd+C etc. + if (e.metaKey || e.ctrlKey || e.altKey) { + return + } + const key = e.key.toLowerCase() + if (key !== 'c' && key !== 's') { + return + } + e.preventDefault() + e.stopPropagation() + handleGrabActionShortcut(key as 'c' | 's') + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [grabIsInteractive, handleGrabActionShortcut]) + + useEffect(() => { + if (!grabIsInteractive) { + return + } + return window.api.browser.onGrabActionShortcut(({ browserPageId, key }) => { + if (browserPageId !== browserTabId) { + return + } + handleGrabActionShortcut(key) + }) + }, [browserTabId, grabIsInteractive, handleGrabActionShortcut]) +} diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts new file mode 100644 index 00000000000..881bca0a47b --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { registerBrowserOverlaySlotViewport } from './browser-page-viewport' +import { useBrowserPageSlotViewport } from './use-browser-page-slot-viewport' + +afterEach(() => { + registerBrowserOverlaySlotViewport('workspace-1', null) +}) + +describe('useBrowserPageSlotViewport', () => { + it('updates when a mounted slot root is replaced', () => { + const first = document.createElement('div') + const second = document.createElement('div') + registerBrowserOverlaySlotViewport('workspace-1', first) + const { result } = renderHook(() => useBrowserPageSlotViewport('workspace-1')) + + expect(result.current).toBe(first) + + act(() => { + registerBrowserOverlaySlotViewport('workspace-1', null) + registerBrowserOverlaySlotViewport('workspace-1', second) + }) + + expect(result.current).toBe(second) + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.ts new file mode 100644 index 00000000000..70f3398071f --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.ts @@ -0,0 +1,15 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { + getBrowserOverlaySlotViewport, + subscribeBrowserOverlaySlotViewport +} from './browser-page-viewport' + +export function useBrowserPageSlotViewport(workspaceId: string): HTMLDivElement | null { + const subscribe = useCallback( + (listener: () => void): (() => void) => + subscribeBrowserOverlaySlotViewport(workspaceId, listener), + [workspaceId] + ) + const getSnapshot = useCallback(() => getBrowserOverlaySlotViewport(workspaceId), [workspaceId]) + return useSyncExternalStore(subscribe, getSnapshot, () => null) +} diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts new file mode 100644 index 00000000000..6750713813a --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts @@ -0,0 +1,312 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type DragEvent, + type MutableRefObject, + type RefObject, + type SetStateAction +} from 'react' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { useAppStore } from '@/store' +import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' +import type { + BrowserLoadError, + BrowserViewportPresetId +} from '../../../../../shared/browser-workspace-types' +import { getBrowserDisplayTitle } from '../describe-page/browser-page-url-display' +import { + EMPTY_BROWSER_ANNOTATIONS, + type BrowserOverlayViewport +} from '../describe-page/browser-annotation-geometry' +import { attachBrowserPageWebview } from './attach-browser-page-webview' +import { setBrowserPageWebviewInputLock } from './browser-page-webview' +import type { + BrowserPageRecoveryNavigationValidation, + BrowserPageUrlSetter, + BrowserTabPageState +} from '../describe-page/browser-page-types' + +export function useBrowserPageWebviewLifecycle({ + browserTabId, + browserTabUrl, + browserTabLoadError, + workspaceId, + worktreeId, + sessionProfileId, + webviewPartition, + isActive, + isPaintable, + slotViewport, + viewportPresetId, + addressBarInputRef, + addressBarValueRef, + browserTabUrlRef, + keepAddressBarFocusRef, + handleInternalFileDragOverRef, + handleInternalFileDropRef, + dismissAddressBarSuggestionsRef, + onUpdatePageState, + onSetUrl, + setAddressBarValue, + setPendingAnnotationPayload, + setBrowserOverlayViewport, + setFindOpen, + focusAddressBarNow, + focusWebviewNow, + paneZoomLevelRef, + setBrowserZoomPercent, + pendingAnnotationPayload, + browserAnnotationsLength, + inputLocked, + faviconUrl, + webviewRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + recoveryNavigationValidationRef, + activeLoadFailureRef, + retryGuestRecoveryRef, + onUpdatePageStateRef, + onSetUrlRef +}: { + browserTabId: string + browserTabUrl: string + browserTabLoadError: BrowserLoadError | null + workspaceId: string + worktreeId: string + sessionProfileId: string | null + webviewPartition: string + isActive: boolean + isPaintable: boolean + slotViewport: HTMLDivElement | null + viewportPresetId: BrowserViewportPresetId | null + addressBarInputRef: RefObject + addressBarValueRef: MutableRefObject + browserTabUrlRef: MutableRefObject + keepAddressBarFocusRef: MutableRefObject + handleInternalFileDragOverRef: MutableRefObject<(event: DragEvent) => void> + handleInternalFileDropRef: MutableRefObject<(event: DragEvent) => void> + dismissAddressBarSuggestionsRef: MutableRefObject<(() => void) | null> + onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + onSetUrl: BrowserPageUrlSetter + setAddressBarValue: Dispatch> + setPendingAnnotationPayload: Dispatch> + setBrowserOverlayViewport: Dispatch> + setFindOpen: Dispatch> + focusAddressBarNow: () => boolean + focusWebviewNow: () => boolean + paneZoomLevelRef: MutableRefObject + setBrowserZoomPercent: Dispatch> + pendingAnnotationPayload: BrowserGrabPayload | null + browserAnnotationsLength: number + inputLocked: boolean + faviconUrl: string | null + webviewRef: MutableRefObject + lastKnownWebviewUrlRef: MutableRefObject + trackNextLoadingEventRef: MutableRefObject + recoveryNavigationValidationRef: MutableRefObject + activeLoadFailureRef: MutableRefObject + retryGuestRecoveryRef: MutableRefObject<() => void> + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> + onSetUrlRef: MutableRefObject +}): { + syncBrowserAnnotationViewportBridge: () => void +} { + const [guestRecoveryGeneration, setGuestRecoveryGeneration] = useState(0) + const guestRecoveryPendingRef = useRef(false) + const validateVisibleGuestRegistrationRef = useRef<() => void>(() => {}) + const wasPaintableForGuestValidationRef = useRef(isPaintable) + const inputLockedRef = useRef(inputLocked) + const faviconUrlRef = useRef(faviconUrl) + const initialBrowserUrlRef = useRef(browserTabUrl) + // Why: CDP viewport emulation doesn't survive renderer process swaps, so reapply the preset from this ref on every dom-ready. + const viewportPresetIdRef = useRef(viewportPresetId) + const addBrowserHistoryEntry = useAppStore((s) => s.addBrowserHistoryEntry) + const addBrowserHistoryEntryRef = useRef(addBrowserHistoryEntry) + const createBrowserTab = useAppStore((s) => s.createBrowserTab) + const isPaintableRef = useRef(isPaintable) + const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', '')) + const isActiveRef = useRef(isActive) + const pendingAnnotationPayloadRef = useRef(pendingAnnotationPayload) + const browserAnnotations = useAppStore( + (s) => s.browserAnnotationsByPageId[browserTabId] ?? EMPTY_BROWSER_ANNOTATIONS + ) + const browserAnnotationsRef = useRef(browserAnnotations) + const clearBrowserPageAnnotations = useAppStore((s) => s.clearBrowserPageAnnotations) + const clearBrowserPageAnnotationsRef = useRef(clearBrowserPageAnnotations) + + useLayoutEffect(() => { + inputLockedRef.current = inputLocked + viewportPresetIdRef.current = viewportPresetId + isActiveRef.current = isActive + pendingAnnotationPayloadRef.current = pendingAnnotationPayload + browserAnnotationsRef.current = browserAnnotations + clearBrowserPageAnnotationsRef.current = clearBrowserPageAnnotations + isPaintableRef.current = isPaintable + }, [ + browserAnnotations, + clearBrowserPageAnnotations, + inputLocked, + isActive, + isPaintable, + pendingAnnotationPayload, + viewportPresetId + ]) + + useLayoutEffect(() => { + const webview = webviewRef.current + if (webview) { + setBrowserPageWebviewInputLock(webview, inputLocked) + } + }, [inputLocked, webviewRef]) + + useEffect(() => { + initialBrowserUrlRef.current = browserTabUrl + }, [browserTabId, browserTabUrl]) + + useEffect(() => { + browserTabUrlRef.current = browserTabUrl + }, [browserTabUrl, browserTabUrlRef]) + + useEffect(() => { + activeLoadFailureRef.current = browserTabLoadError + }, [activeLoadFailureRef, browserTabLoadError]) + + useEffect(() => { + onUpdatePageStateRef.current = onUpdatePageState + onSetUrlRef.current = onSetUrl + addBrowserHistoryEntryRef.current = addBrowserHistoryEntry + }, [onSetUrl, onUpdatePageState, addBrowserHistoryEntry, onSetUrlRef, onUpdatePageStateRef]) + + const syncNavigationState = useCallback( + (webview: Electron.WebviewTag): void => { + try { + onUpdatePageStateRef.current(browserTabId, { + title: getBrowserDisplayTitle( + webview.getTitle(), + webview.getURL() || browserTabUrlRef.current + ), + // Why: attach can transiently report isLoading() with no real navigation; syncing it would flash the loading dot on tab switches. + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward() + }) + } catch { + // Why: these getters only exist after the guest fully attaches; ignore the transient failure during attach. + } + }, + [browserTabId, browserTabUrlRef, onUpdatePageStateRef] + ) + + const syncBrowserAnnotationViewportBridge = useCallback((): void => { + const pendingPayload = pendingAnnotationPayloadRef.current + // Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages. + const markers = browserAnnotationsRef.current.map((annotation, index) => ({ + id: annotation.id, + index, + isFixed: annotation.payload.target.isFixed === true, + rectPage: annotation.payload.target.rectPage, + rectViewport: annotation.payload.target.rectViewport + })) + const enabled = isActiveRef.current && (pendingPayload !== null || markers.length > 0) + void window.api.browser + .setAnnotationViewportBridge({ + browserPageId: browserTabId, + emitViewport: pendingPayload !== null, + enabled, + markers, + token: annotationViewportBridgeTokenRef.current + }) + .catch(() => { + // The viewport bridge is visual-only; stale markers beat breaking the pane on a destroyed guest. + }) + }, [browserTabId]) + + // Why: browserTab.url excluded from deps (changes every navigation → would destroy/recreate the webview); URL logic reads browserTabUrlRef. + useEffect(() => { + return attachBrowserPageWebview({ + browserTabId, + browserTabUrl, + workspaceId, + worktreeId, + sessionProfileId, + webviewPartition, + isActive, + isPaintable, + inputLockedRef, + webviewRef, + handleInternalFileDragOverRef, + handleInternalFileDropRef, + dismissAddressBarSuggestionsRef, + isPaintableRef, + guestRecoveryPendingRef, + browserTabUrlRef, + addressBarValueRef, + activeLoadFailureRef, + recoveryNavigationValidationRef, + keepAddressBarFocusRef, + paneZoomLevelRef, + viewportPresetIdRef, + onUpdatePageStateRef, + setGuestRecoveryGeneration, + setBrowserZoomPercent, + focusAddressBarNow, + syncNavigationState, + syncBrowserAnnotationViewportBridge, + faviconUrlRef, + addressBarInputRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + clearBrowserPageAnnotationsRef, + onSetUrlRef, + setPendingAnnotationPayload, + setBrowserOverlayViewport, + setAddressBarValue, + addBrowserHistoryEntryRef, + annotationViewportBridgeTokenRef, + initialBrowserUrlRef, + validateVisibleGuestRegistrationRef, + retryGuestRecoveryRef, + setFindOpen + }) + // Why: wire listeners once per tab identity. browserTab.url is excluded (re-running would detach/reattach and cancel navigations; callbacks use refs). + // webviewPartition IS included: Electron can't change a webview's partition after creation, so a profile switch must recreate it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + browserTabId, + guestRecoveryGeneration, + workspaceId, + slotViewport, + webviewPartition, + worktreeId, + createBrowserTab, + focusAddressBarNow, + focusWebviewNow, + syncNavigationState, + syncBrowserAnnotationViewportBridge + ]) + + useEffect(() => { + const becamePaintable = isPaintable && !wasPaintableForGuestValidationRef.current + wasPaintableForGuestValidationRef.current = isPaintable + if (becamePaintable) { + validateVisibleGuestRegistrationRef.current() + } + }, [isPaintable]) + + useEffect(() => { + syncBrowserAnnotationViewportBridge() + }, [ + browserAnnotationsLength, + browserTabId, + isActive, + pendingAnnotationPayload, + syncBrowserAnnotationViewportBridge + ]) + + return { + syncBrowserAnnotationViewportBridge + } +} diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.ts new file mode 100644 index 00000000000..6b0324d2444 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.ts @@ -0,0 +1,28 @@ +import { useAppStore } from '@/store' +import { ORCA_BROWSER_PARTITION } from '../../../../../shared/constants' +import { getOrcaProfileBrowserDefaultPartition } from '../../../../../shared/orca-profiles' + +export function useBrowserPageWebviewPartition({ + sessionProfileId, + sessionPartition +}: { + sessionProfileId: string | null + sessionPartition: string | null +}): string { + const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) + const activeOrcaProfileId = useAppStore((s) => s.activeOrcaProfileId) + const fallbackBrowserPartition = activeOrcaProfileId + ? getOrcaProfileBrowserDefaultPartition(activeOrcaProfileId) + : null + const defaultSessionProfile = browserSessionProfiles.find((p) => p.id === 'default') ?? null + const sessionProfile = sessionProfileId + ? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null) + : defaultSessionProfile + return ( + sessionPartition ?? + sessionProfile?.partition ?? + defaultSessionProfile?.partition ?? + fallbackBrowserPartition ?? + ORCA_BROWSER_PARTITION + ) +} diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts new file mode 100644 index 00000000000..368d66d5e8d --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { + browserPageZoomLevelToPercent, + DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, + getExplicitBrowserPageZoomLevel, + normalizeBrowserPageZoomLevel +} from './browser-page-zoom' +import { BROWSER_PAGE_ZOOM_FEEDBACK_MS } from '../describe-page/browser-annotation-geometry' + +export function useBrowserPageZoomFeedback(browserTabId: string): { + paneZoomLevelRef: React.MutableRefObject + browserZoomPercent: number + setBrowserZoomPercent: React.Dispatch> + browserZoomFeedbackVisible: boolean + showBrowserZoomFeedback: (level: number) => void + browserDefaultZoomPercent: number + setBrowserDefaultZoomLevel: (level: number) => void +} { + const browserDefaultZoomLevel = useAppStore( + (state) => state.browserDefaultZoomLevel ?? DEFAULT_BROWSER_PAGE_ZOOM_LEVEL + ) + const setBrowserDefaultZoomLevel = useAppStore((state) => state.setBrowserDefaultZoomLevel) + const normalizedBrowserDefaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel) + const browserDefaultZoomPercent = browserPageZoomLevelToPercent(normalizedBrowserDefaultZoomLevel) + // Why: the level THIS pane should hold. Seeded from the configured default ("applied to newly + // opened browser tabs") and moved only by zooming this pane, so a reload can't broadcast another + // tab's zoom through the shared setting. Why the module-level lookup: the guest webview outlives + // this component (worktree switch, Settings visit), so re-seeding on remount would let a later + // Settings change retroactively hijack a tab the user already zoomed. + const paneZoomLevelRef = useRef( + getExplicitBrowserPageZoomLevel(browserTabId) ?? normalizedBrowserDefaultZoomLevel + ) + const [browserZoomPercent, setBrowserZoomPercent] = useState(browserDefaultZoomPercent) + const [browserZoomFeedbackVisible, setBrowserZoomFeedbackVisible] = useState(false) + const browserZoomFeedbackTimerRef = useRef>(undefined) + useEffect(() => { + return () => { + clearTimeout(browserZoomFeedbackTimerRef.current) + } + }, []) + const showBrowserZoomFeedback = useCallback((level: number): void => { + setBrowserZoomPercent(browserPageZoomLevelToPercent(level)) + setBrowserZoomFeedbackVisible(true) + clearTimeout(browserZoomFeedbackTimerRef.current) + browserZoomFeedbackTimerRef.current = setTimeout(() => { + setBrowserZoomFeedbackVisible(false) + }, BROWSER_PAGE_ZOOM_FEEDBACK_MS) + }, []) + + return { + paneZoomLevelRef, + browserZoomPercent, + setBrowserZoomPercent, + browserZoomFeedbackVisible, + showBrowserZoomFeedback, + browserDefaultZoomPercent, + setBrowserDefaultZoomLevel + } +} diff --git a/src/renderer/src/components/browser-pane/webview-registry.test.ts b/src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/webview-registry.test.ts rename to src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/host-guest/webview-registry.ts similarity index 99% rename from src/renderer/src/components/browser-pane/webview-registry.ts rename to src/renderer/src/components/browser-pane/host-guest/webview-registry.ts index f64f0dfc0e3..6a5aa6ed856 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/host-guest/webview-registry.ts @@ -1,4 +1,4 @@ -import { clearLiveBrowserUrl } from './browser-runtime' +import { clearLiveBrowserUrl } from '../describe-page/live-browser-url-registry' import { removeBrowserPageViewport } from './browser-page-viewport' import { forgetExplicitBrowserPageZoomLevel } from './browser-page-zoom' diff --git a/src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts new file mode 100644 index 00000000000..c7fb944568f --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + formatBrowserDownloadProgress, + type BrowserDownloadState +} from './browser-download-progress' + +function download(overrides: Partial = {}): BrowserDownloadState { + return { + downloadId: 'd1', + browserPageId: 'p1', + filename: 'file.bin', + mimeType: 'application/octet-stream', + origin: 'https://example.com', + totalBytes: 2048, + receivedBytes: 1024, + status: 'downloading', + savePath: null, + error: null, + progressState: 'progressing', + completedAt: null, + url: 'https://example.com/file.bin', + ...overrides + } as BrowserDownloadState +} + +describe('formatBrowserDownloadProgress', () => { + it('joins received and total when both are known', () => { + expect(formatBrowserDownloadProgress(download())).toBe('1.0 KB / 2.0 KB') + }) + + it('falls back to the single known side', () => { + expect(formatBrowserDownloadProgress(download({ totalBytes: null }))).toBe('1.0 KB') + expect(formatBrowserDownloadProgress(download({ receivedBytes: -1, totalBytes: 2048 }))).toBe( + '2.0 KB' + ) + }) +}) diff --git a/src/renderer/src/components/browser-pane/navigate/browser-download-progress.ts b/src/renderer/src/components/browser-pane/navigate/browser-download-progress.ts new file mode 100644 index 00000000000..85bd55a1e46 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-download-progress.ts @@ -0,0 +1,23 @@ +import type { + BrowserDownloadRequestedEvent, + BrowserDownloadProgressEvent +} from '../../../../../shared/browser-guest-events' +import { formatByteCount } from './browser-notices' + +export type BrowserDownloadState = Omit & { + receivedBytes: number + status: 'downloading' | 'completed' | 'failed' | 'canceled' + savePath: string | null + error: string | null + progressState: BrowserDownloadProgressEvent['state'] + completedAt: number | null +} + +export function formatBrowserDownloadProgress(download: BrowserDownloadState): string | null { + const received = formatByteCount(download.receivedBytes) + const total = formatByteCount(download.totalBytes) + if (received && total) { + return `${received} / ${total}` + } + return received ?? total +} diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx b/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx similarity index 99% rename from src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx rename to src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx index e840900c5a2..59add44e812 100644 --- a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx +++ b/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, describe, expect, it, vi } from 'vitest' import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' -import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-guest-recovery' const callbacks = { onRetry: vi.fn(), diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx b/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.tsx similarity index 97% rename from src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx rename to src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.tsx index 7c2e41d2904..da03c7c484e 100644 --- a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx +++ b/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.tsx @@ -8,16 +8,16 @@ import type { BrowserCertificateProceedFailureReason, BrowserCertificateProceedResult, BrowserLoadError -} from '../../../../shared/browser-workspace-types' -import { isEligibleLocalCertificateHost } from '../../../../shared/browser-url' -import { normalizeCertificateError } from '../../../../shared/browser-certificate-errors' +} from '../../../../../shared/browser-workspace-types' +import { isEligibleLocalCertificateHost } from '../../../../../shared/browser-url' +import { normalizeCertificateError } from '../../../../../shared/browser-certificate-errors' import { formatLoadFailureDescription, formatLoadFailureRecoveryHint, isCertificateLoadError, type LoadFailureMeta } from './browser-notices' -import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-guest-recovery' type BrowserLoadFailureOverlayProps = { loadError: BrowserLoadError diff --git a/src/renderer/src/components/browser-pane/browser-notices.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts similarity index 98% rename from src/renderer/src/components/browser-pane/browser-notices.test.ts rename to src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts index 0bef2584dc0..509dc8a08a2 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts @@ -8,7 +8,7 @@ import { formatPopupNotice, isCertificateLoadError } from './browser-notices' -import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-guest-recovery' describe('browser notice formatting', () => { it('formats denied permissions with safe copy', () => { diff --git a/src/renderer/src/components/browser-pane/browser-notices.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts similarity index 92% rename from src/renderer/src/components/browser-pane/browser-notices.ts rename to src/renderer/src/components/browser-pane/navigate/browser-notices.ts index 701a074186e..76888fc7b94 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts @@ -2,11 +2,11 @@ import type { BrowserDownloadFinishedEvent, BrowserPermissionDeniedEvent, BrowserPopupEvent -} from '../../../../shared/browser-guest-events' -import type { BrowserLoadError } from '../../../../shared/browser-workspace-types' -import { isChromiumCertificateErrorCode } from '../../../../shared/browser-certificate-errors' +} from '../../../../../shared/browser-guest-events' +import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' +import { isChromiumCertificateErrorCode } from '../../../../../shared/browser-certificate-errors' import { translate } from '@/i18n/i18n' -import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-guest-recovery' export type LoadFailureMeta = { host: string | null diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts rename to src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts b/src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.ts similarity index 100% rename from src/renderer/src/components/browser-pane/browser-page-download-activity.ts rename to src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.ts diff --git a/src/renderer/src/components/browser-pane/navigate/browser-page-download-list.tsx b/src/renderer/src/components/browser-pane/navigate/browser-page-download-list.tsx new file mode 100644 index 00000000000..6cc53cb1a61 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-page-download-list.tsx @@ -0,0 +1,144 @@ +import { CircleCheck, Download, ExternalLink, FolderOpen, OctagonX, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { + formatBrowserDownloadProgress, + type BrowserDownloadState +} from './browser-download-progress' + +export function BrowserPageDownloadList({ + visibleDownloads, + onOpenDownloadedFile, + onShowDownloadedFile, + onDismissDownload +}: { + visibleDownloads: BrowserDownloadState[] + onOpenDownloadedFile: (download: BrowserDownloadState) => void + onShowDownloadedFile: (download: BrowserDownloadState) => void + onDismissDownload: (downloadId: string) => void +}): React.JSX.Element | null { + if (visibleDownloads.length === 0) { + return null + } + + return ( +
+
+ {visibleDownloads.map((download) => { + const progressLabel = formatBrowserDownloadProgress(download) + const statusLabel = + download.status === 'downloading' + ? download.progressState === 'interrupted' + ? translate( + 'auto.components.browser.pane.BrowserPane.39c04fed61', + 'Downloading paused' + ) + : (progressLabel ?? + translate('auto.components.browser.pane.BrowserPane.759f32af29', 'Downloading')) + : download.status === 'completed' + ? translate('auto.components.browser.pane.BrowserPane.5c3d530a68', 'Downloaded') + : download.status === 'canceled' + ? translate('auto.components.browser.pane.BrowserPane.4bb7424d6b', 'Canceled') + : (download.error ?? + translate( + 'auto.components.browser.pane.BrowserPane.6e776f9ef9', + 'Download failed' + )) + return ( +
+ {download.status === 'completed' ? ( + + ) : download.status === 'failed' ? ( + + ) : ( + + )} +
+
{download.filename}
+
+ {download.status === 'downloading' + ? translate( + 'auto.components.browser.pane.BrowserPane.4300f38145', + 'Downloading from {{value0}}{{value1}}', + { + value0: download.origin, + value1: statusLabel ? ` • ${statusLabel}` : '' + } + ) + : statusLabel} +
+
+ {download.status === 'downloading' ? ( + + ) : download.status === 'completed' ? ( + <> + + + + + ) : ( + + )} +
+ ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts similarity index 98% rename from src/renderer/src/components/browser-pane/browser-reload-action.test.ts rename to src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts index 2f9748767a1..aa001aaef1e 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-guest-recovery' import { reloadBrowserPageWebview, resolveBrowserReloadButtonLabelKind, diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.ts b/src/renderer/src/components/browser-pane/navigate/browser-reload-action.ts similarity index 96% rename from src/renderer/src/components/browser-pane/browser-reload-action.ts rename to src/renderer/src/components/browser-pane/navigate/browser-reload-action.ts index b4ecb6d9b4f..40ef5c7fa5e 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-reload-action.ts @@ -1,4 +1,4 @@ -import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-guest-recovery' /** Where the reload request came from: the toolbar button, or an explicit menu entry. */ export type BrowserReloadTrigger = 'button' | 'reload' | 'hard-reload' diff --git a/src/renderer/src/components/browser-pane/chromium-error-page-polling.test.ts b/src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/chromium-error-page-polling.test.ts rename to src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts diff --git a/src/renderer/src/components/browser-pane/chromium-error-page-polling.ts b/src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.ts similarity index 100% rename from src/renderer/src/components/browser-pane/chromium-error-page-polling.ts rename to src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.ts diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts new file mode 100644 index 00000000000..bf7ccd0a313 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -0,0 +1,144 @@ +import { detectLanguage } from '@/lib/language-detect' +import { getConnectionId } from '@/lib/connection-context' +import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' +import { useAppStore } from '@/store' +import { + isRemoteRuntimeFileOperation, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../../shared/browser-url' +import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-guest-recovery' +import { + getBrowserDisplayTitle, + getNotebookPathFromBrowserUrl, + toDisplayUrl +} from '../describe-page/browser-page-url-display' +import type { + BrowserPageRecoveryNavigationValidation, + BrowserPageUrlSetter, + BrowserTabPageState +} from '../describe-page/browser-page-types' +import type { MutableRefObject } from 'react' + +export type NavigateBrowserPageToUrlArgs = { + url: string + browserTabId: string + worktreeId: string + activeLoadFailureRef: MutableRefObject + lastKnownWebviewUrlRef: MutableRefObject + trackNextLoadingEventRef: MutableRefObject + recoveryNavigationValidationRef: MutableRefObject + webviewRef: MutableRefObject + onSetUrlRef: MutableRefObject + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> + setAddressBarValue: (value: string) => void + setResourceNotice: (notice: string | null) => void + focusWebviewNow: () => boolean +} + +export function navigateBrowserPageToUrl({ + url, + browserTabId, + worktreeId, + activeLoadFailureRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + recoveryNavigationValidationRef, + webviewRef, + onSetUrlRef, + onUpdatePageStateRef, + setAddressBarValue, + setResourceNotice, + focusWebviewNow +}: NavigateBrowserPageToUrlArgs): void { + const navigateBrowserUrl = (targetUrl: string): void => { + const browserModelUrl = redactKagiSessionToken(targetUrl) + const normalizedBrowserModelUrl = + normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl + const recoveryLoadError = + activeLoadFailureRef.current?.code === BROWSER_GUEST_RECOVERY_ERROR_CODE + ? activeLoadFailureRef.current + : null + setAddressBarValue(toDisplayUrl(browserModelUrl)) + onSetUrlRef.current(browserTabId, browserModelUrl) + onUpdatePageStateRef.current(browserTabId, { + loading: true, + loadError: recoveryLoadError, + title: getBrowserDisplayTitle(browserModelUrl, browserModelUrl) + }) + setResourceNotice(null) + + const webview = webviewRef.current + if (!webview) { + return + } + trackNextLoadingEventRef.current = targetUrl !== ORCA_BROWSER_BLANK_URL + lastKnownWebviewUrlRef.current = normalizedBrowserModelUrl + recoveryNavigationValidationRef.current = recoveryLoadError + ? { committed: false, started: false, targetUrl: normalizedBrowserModelUrl } + : null + webview.src = targetUrl + if (targetUrl !== ORCA_BROWSER_BLANK_URL) { + focusWebviewNow() + } + } + + const notebookPath = getNotebookPathFromBrowserUrl(url) + if (notebookPath) { + void (async () => { + const store = useAppStore.getState() + const connectionId = getConnectionId(worktreeId) + if (connectionId !== null) { + navigateBrowserUrl(url) + return + } + + try { + const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId) + const fileContext: RuntimeFileOperationArgs = { + settings: store.settings, + worktreeId, + worktreePath: activeWorktree?.path, + connectionId: undefined + } + if (!isRemoteRuntimeFileOperation(fileContext, notebookPath)) { + await window.api.fs.authorizeExternalPath({ targetPath: notebookPath }) + } + const stat = await statRuntimePath(fileContext, notebookPath) + if (stat.isDirectory) { + navigateBrowserUrl(url) + return + } + + let relativePath = notebookPath + if (activeWorktree?.path && isPathInsideWorktree(notebookPath, activeWorktree.path)) { + relativePath = toWorktreeRelativePath(notebookPath, activeWorktree.path) ?? notebookPath + } + + // Why: file:// notebooks in the browser are otherwise rendered as raw JSON by Chromium. + store.setActiveTabType('editor') + store.openFile( + { + filePath: notebookPath, + relativePath, + worktreeId, + language: detectLanguage(notebookPath), + mode: 'edit' + }, + { preview: false, targetGroupId: store.ensureWorktreeRootGroup(worktreeId) } + ) + } catch { + navigateBrowserUrl(url) + } + })() + return + } + + navigateBrowserUrl(url) +} diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-download-events.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-download-events.ts new file mode 100644 index 00000000000..fe79cf3ee1c --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-download-events.ts @@ -0,0 +1,93 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent +} from '../../../../../shared/browser-guest-events' +import type { BrowserDownloadState } from './browser-download-progress' + +export function useBrowserPageDownloadEvents({ + browserTabId, + setResourceNotice +}: { + browserTabId: string + setResourceNotice: Dispatch> +}): { + downloadStates: BrowserDownloadState[] + setDownloadStates: Dispatch> +} { + const [downloadStates, setDownloadStates] = useState([]) + + useEffect(() => { + return window.api.browser.onDownloadRequested((event) => { + if (event.browserPageId !== browserTabId) { + return + } + setDownloadStates((current) => { + const nextEntry: BrowserDownloadState = { + browserPageId: event.browserPageId, + downloadId: event.downloadId, + origin: event.origin, + filename: event.filename, + totalBytes: event.totalBytes, + mimeType: event.mimeType, + receivedBytes: 0, + status: 'downloading', + savePath: event.savePath, + error: null, + progressState: null, + completedAt: null + } + const existingIndex = current.findIndex( + (download) => download.downloadId === event.downloadId + ) + if (existingIndex === -1) { + return [nextEntry, ...current] + } + const next = [...current] + next[existingIndex] = { ...next[existingIndex], ...nextEntry } + return next + }) + setResourceNotice(null) + }) + }, [browserTabId, setResourceNotice]) + + useEffect(() => { + return window.api.browser.onDownloadProgress((event: BrowserDownloadProgressEvent) => { + setDownloadStates((current) => + current.map((download) => + download.downloadId === event.downloadId + ? { + ...download, + receivedBytes: event.receivedBytes, + totalBytes: event.totalBytes, + progressState: event.state + } + : download + ) + ) + }) + }, []) + + useEffect(() => { + return window.api.browser.onDownloadFinished((event: BrowserDownloadFinishedEvent) => { + if (event.browserPageId && event.browserPageId !== browserTabId) { + return + } + setDownloadStates((current) => + current.map((download) => + download.downloadId === event.downloadId + ? { + ...download, + status: event.status, + savePath: event.savePath, + error: event.error, + completedAt: Date.now() + } + : download + ) + ) + }) + }, [browserTabId]) + + return { downloadStates, setDownloadStates } +} diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts new file mode 100644 index 00000000000..183437e5b67 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts @@ -0,0 +1,301 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type DragEvent, + type MutableRefObject, + type SetStateAction +} from 'react' +import { translate } from '@/i18n/i18n' +import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview' +import { + getWorkspaceFileDragRejectionMessage, + readWorkspaceFileDragPaths, + WORKSPACE_FILE_PATH_MIME +} from '@/lib/workspace-file-drag' +import { useAppStore } from '@/store' +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../../shared/browser-url' +import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types' +import { navigateBrowserPageToUrl } from './navigate-browser-page-url' +import type { BrowserDownloadState } from './browser-download-progress' +import { toDisplayUrl } from '../describe-page/browser-page-url-display' +import { useBrowserPageDownloadEvents } from './use-browser-page-download-events' +import type { + BrowserPageRecoveryNavigationValidation, + BrowserPageUrlSetter, + BrowserTabPageState +} from '../describe-page/browser-page-types' + +export function useBrowserPageNavigationDownloads({ + browserTabId, + worktreeId, + webviewRef, + activeLoadFailureRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + recoveryNavigationValidationRef, + onSetUrlRef, + onUpdatePageStateRef, + keepAddressBarFocusRef, + focusWebviewNow, + setResourceNotice, + addressBarValueRef, + addressBarInputRef, + browserTabUrl +}: { + browserTabId: string + worktreeId: string + webviewRef: MutableRefObject + activeLoadFailureRef: MutableRefObject + lastKnownWebviewUrlRef: MutableRefObject + trackNextLoadingEventRef: MutableRefObject + recoveryNavigationValidationRef: MutableRefObject + onSetUrlRef: MutableRefObject + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> + keepAddressBarFocusRef: MutableRefObject + focusWebviewNow: () => boolean + setResourceNotice: Dispatch> + addressBarValueRef: MutableRefObject + addressBarInputRef: MutableRefObject + browserTabUrl: string +}): { + addressBarValue: string + setAddressBarValue: Dispatch> + submitAddressBar: () => void + navigateToUrl: (url: string) => void + visibleDownloads: BrowserDownloadState[] + dismissBrowserDownload: (downloadId: string) => void + handleOpenDownloadedFile: (download: BrowserDownloadState) => Promise + handleShowDownloadedFile: (download: BrowserDownloadState) => Promise + handleInternalFileDragOverRef: MutableRefObject<(event: DragEvent) => void> + handleInternalFileDropRef: MutableRefObject<(event: DragEvent) => void> +} { + const [addressBarValue, setAddressBarValue] = useState(() => toDisplayUrl(browserTabUrl)) + const { downloadStates, setDownloadStates } = useBrowserPageDownloadEvents({ + browserTabId, + setResourceNotice + }) + const handleInternalFileDragOverRef = useRef<(event: DragEvent) => void>(() => {}) + const handleInternalFileDropRef = useRef<(event: DragEvent) => void>(() => {}) + + useEffect(() => { + // Why: don't clobber an in-progress address-bar query when an async URL update lands; syncing resumes once the input blurs. + if (document.activeElement === addressBarInputRef.current) { + return + } + setAddressBarValue(toDisplayUrl(browserTabUrl)) + }, [addressBarInputRef, browserTabUrl]) + + useEffect(() => { + addressBarValueRef.current = addressBarValue + }, [addressBarValue, addressBarValueRef]) + + const navigateToUrl = useCallback( + (url: string): void => { + navigateBrowserPageToUrl({ + url, + browserTabId, + worktreeId, + activeLoadFailureRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + recoveryNavigationValidationRef, + webviewRef, + onSetUrlRef, + onUpdatePageStateRef, + setAddressBarValue, + setResourceNotice, + focusWebviewNow + }) + }, + [ + activeLoadFailureRef, + browserTabId, + focusWebviewNow, + lastKnownWebviewUrlRef, + onSetUrlRef, + onUpdatePageStateRef, + recoveryNavigationValidationRef, + setResourceNotice, + trackNextLoadingEventRef, + webviewRef, + worktreeId + ] + ) + + const submitAddressBar = (): void => { + keepAddressBarFocusRef.current = false + const searchEngine = useAppStore.getState().browserDefaultSearchEngine + const kagiSessionLink = useAppStore.getState().browserKagiSessionLink + const nextUrl = normalizeBrowserNavigationUrl(addressBarValue, searchEngine, { + kagiSessionLink + }) + if (!nextUrl) { + onUpdatePageStateRef.current(browserTabId, { + loadError: { + code: 0, + description: translate( + 'auto.components.browser.pane.BrowserPane.87eb75f7d2', + 'Enter a valid http(s) or localhost URL.' + ), + // Why: redact a possible Kagi session token before persisting into loadError. + validatedUrl: redactKagiSessionToken(addressBarValue.trim()) || 'about:blank' + } + }) + return + } + navigateToUrl(nextUrl) + } + + const handleInternalFileDragOver = useCallback((event: DragEvent) => { + if (!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) { + return + } + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'copy' + }, []) + + const handleInternalFileDrop = useCallback( + (event: DragEvent) => { + if (!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) { + return + } + event.preventDefault() + event.stopPropagation() + + // Why: a browser opens one URL, so reject multi-path drags rather than silently opening the lead file. + const dragPaths = readWorkspaceFileDragPaths(event.dataTransfer, { maxPaths: 1 }) + if (dragPaths.status === 'rejected') { + setResourceNotice(getWorkspaceFileDragRejectionMessage(dragPaths.reason)) + return + } + const filePath = dragPaths.paths[0] + if (!filePath) { + return + } + + const target = getWorkspaceFileBrowserOpenTarget({ filePath, worktreeId }) + if (target.status === 'unsupported') { + setResourceNotice(target.message) + return + } + + const webview = webviewRef.current + const rect = webview?.getBoundingClientRect() + if (!webview || !rect) { + setResourceNotice( + translate( + 'auto.components.browser.pane.navigate.use.browser.page.navigation.downloads.8683b84b9e', + 'Browser page is not ready for file drops.' + ) + ) + return + } + const pageX = event.clientX - rect.left + const pageY = event.clientY - rect.top + if (pageX < 0 || pageY < 0 || pageX > rect.width || pageY > rect.height) { + setResourceNotice( + translate( + 'auto.components.browser.pane.navigate.use.browser.page.navigation.downloads.22272f2784', + 'Drop files over the browser page, not the toolbar.' + ) + ) + return + } + + navigateToUrl(target.url) + }, + [navigateToUrl, setResourceNotice, webviewRef, worktreeId] + ) + + useLayoutEffect(() => { + handleInternalFileDragOverRef.current = handleInternalFileDragOver + handleInternalFileDropRef.current = handleInternalFileDrop + }, [handleInternalFileDragOver, handleInternalFileDrop]) + + const dismissBrowserDownload = useCallback( + (downloadId: string) => { + setDownloadStates((current) => + current.filter((download) => download.downloadId !== downloadId) + ) + }, + [setDownloadStates] + ) + + const handleOpenDownloadedFile = useCallback( + async (download: BrowserDownloadState) => { + if (!download.savePath) { + setResourceNotice( + translate( + 'auto.components.browser.pane.BrowserPane.9f6f2e8c19', + 'The downloaded file path is unavailable.' + ) + ) + return + } + const opened = await window.api.shell.openFilePath(download.savePath) + if (!opened) { + setResourceNotice( + translate( + 'auto.components.browser.pane.BrowserPane.0c79b7634d', + 'Could not open the downloaded file. It may have been moved or deleted.' + ) + ) + } + }, + [setResourceNotice] + ) + + const handleShowDownloadedFile = useCallback( + async (download: BrowserDownloadState) => { + if (!download.savePath) { + setResourceNotice( + translate( + 'auto.components.browser.pane.BrowserPane.9f6f2e8c19', + 'The downloaded file path is unavailable.' + ) + ) + return + } + const result = await window.api.shell.openInFileManager(download.savePath) + if (!result.ok) { + setResourceNotice( + translate( + 'auto.components.browser.pane.BrowserPane.397d9dc923', + 'Could not show the downloaded file. It may have been moved or deleted.' + ) + ) + } + }, + [setResourceNotice] + ) + + const visibleDownloads = (() => { + const active = downloadStates.filter((download) => download.status === 'downloading') + const recent = downloadStates + .filter((download) => download.status !== 'downloading') + .sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0)) + .slice(0, 3) + return [...active, ...recent] + })() + + return { + addressBarValue, + setAddressBarValue, + submitAddressBar, + navigateToUrl, + visibleDownloads, + dismissBrowserDownload, + handleOpenDownloadedFile, + handleShowDownloadedFile, + handleInternalFileDragOverRef, + handleInternalFileDropRef + } +} diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts new file mode 100644 index 00000000000..b9adccd52c8 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts @@ -0,0 +1,109 @@ +import { useCallback, useMemo, useState, type MutableRefObject } from 'react' +import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' +import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' +import { + type BrowserReloadTrigger, + reloadBrowserPageWebview, + resolveBrowserReloadButtonLabelKind, + resolveBrowserReloadIntent +} from './browser-reload-action' +import { retryBrowserTabLoad } from '../describe-page/browser-page-url-display' +import type { BrowserTabPageState } from '../describe-page/browser-page-types' + +export function useBrowserPageReloadActions({ + browserTab, + webviewRef, + retryGuestRecoveryRef, + onUpdatePageStateRef +}: { + browserTab: BrowserPageState + webviewRef: MutableRefObject + retryGuestRecoveryRef: MutableRefObject<() => void> + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> +}): { + reloadWebviewOrRecoverGuest: (ignoreCache: boolean) => void + runReloadTrigger: (trigger: BrowserReloadTrigger) => void + reloadButtonLabel: string + reloadButtonLabelKind: ReturnType + reloadMenuOpen: boolean + setReloadMenuOpen: React.Dispatch> + reloadShortcut: string + hardReloadShortcut: string +} { + const reloadShortcut = useShortcutLabel('browser.reload') + const hardReloadShortcut = useShortcutLabel('browser.hardReload') + const [reloadMenuOpen, setReloadMenuOpen] = useState(false) + const reloadState = useMemo( + () => ({ loading: browserTab.loading, loadErrorCode: browserTab.loadError?.code ?? null }), + [browserTab.loading, browserTab.loadError] + ) + const reloadWebviewOrRecoverGuest = useCallback( + (ignoreCache: boolean) => { + const webview = webviewRef.current + if (!webview) { + return + } + if (reloadBrowserPageWebview(webview, { ignoreCache }) === 'guest-missing') { + // Why: reload cannot revive a destroyed guest (STA-3448) — recreate it instead. + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryGuestRecoveryRef.current() + } + }, + [browserTab.id, onUpdatePageStateRef, retryGuestRecoveryRef, webviewRef] + ) + const runReloadTrigger = useCallback( + (trigger: BrowserReloadTrigger) => { + const webview = webviewRef.current + if (!webview) { + return + } + switch (resolveBrowserReloadIntent(trigger, reloadState)) { + case 'stop': + webview.stop() + break + case 'retry-guest-recovery': + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryGuestRecoveryRef.current() + break + case 'retry-load': + retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) + break + case 'hard-reload': + reloadWebviewOrRecoverGuest(true) + break + case 'reload': + reloadWebviewOrRecoverGuest(false) + break + } + }, + [ + browserTab, + onUpdatePageStateRef, + reloadState, + reloadWebviewOrRecoverGuest, + retryGuestRecoveryRef, + webviewRef + ] + ) + + // Keep the accessible name honest: the same button is Stop mid-load and Retry after a failure. + const reloadButtonLabelKind = resolveBrowserReloadButtonLabelKind(reloadState) + const reloadButtonLabel = + reloadButtonLabelKind === 'stop' + ? translate('auto.components.browser.pane.BrowserPane.b7e4d9c1a2', 'Stop') + : reloadButtonLabelKind === 'retry' + ? translate('auto.components.browser.pane.BrowserPane.781d6459ad', 'Retry') + : translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload') + + return { + reloadWebviewOrRecoverGuest, + runReloadTrigger, + reloadButtonLabel, + reloadButtonLabelKind, + reloadMenuOpen, + setReloadMenuOpen, + reloadShortcut, + hardReloadShortcut + } +} diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-resource-notices.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-resource-notices.ts new file mode 100644 index 00000000000..e3be43f4aac --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-resource-notices.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react' +import { useAppStore } from '@/store' +import { formatPermissionNotice, formatPopupNotice } from './browser-notices' + +export function useBrowserPageResourceNotices(browserTabId: string): { + resourceNotice: string | null + setResourceNotice: React.Dispatch> +} { + const [resourceNotice, setResourceNotice] = useState(null) + const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) + const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState) + + useEffect(() => { + if (!browserSessionImportState) { + return + } + if (browserSessionImportState.status === 'success' && browserSessionImportState.summary) { + const { importedCookies, domains } = browserSessionImportState.summary + const domainPreview = domains.slice(0, 3).join(', ') + const more = domains.length > 3 ? ` +${domains.length - 3} more` : '' + setResourceNotice( + `Imported ${importedCookies} cookies for ${domainPreview}${more}. Reload the page to use them.` + ) + clearBrowserSessionImportState() + } else if (browserSessionImportState.status === 'error' && browserSessionImportState.error) { + setResourceNotice(`Cookie import failed: ${browserSessionImportState.error}`) + clearBrowserSessionImportState() + } + }, [browserSessionImportState, clearBrowserSessionImportState]) + + useEffect(() => { + if (!resourceNotice) { + return + } + const timer = setTimeout(() => setResourceNotice(null), 10_000) + return () => clearTimeout(timer) + }, [resourceNotice]) + + useEffect(() => { + return window.api.browser.onPermissionDenied((event) => { + if (event.browserPageId !== browserTabId) { + return + } + setResourceNotice(formatPermissionNotice(event)) + }) + }, [browserTabId]) + + useEffect(() => { + return window.api.browser.onPopup((event) => { + if (event.browserPageId !== browserTabId) { + return + } + setResourceNotice(formatPopupNotice(event)) + }) + }, [browserTabId]) + + return { resourceNotice, setResourceNotice } +} diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-webview-url-sync.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-webview-url-sync.ts new file mode 100644 index 00000000000..0c75ddefce0 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-webview-url-sync.ts @@ -0,0 +1,167 @@ +import { useEffect, useLayoutEffect, type MutableRefObject, type RefObject } from 'react' +import { translate } from '@/i18n/i18n' +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../../shared/browser-url' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { + applyBrowserPageViewportLayout, + syncBrowserPageChromeInset +} from '../host-guest/browser-page-viewport' +import { shouldPollChromiumErrorPage } from './chromium-error-page-polling' +import { isChromiumErrorPage } from '../describe-page/browser-page-url-display' +import type { BrowserTabPageState } from '../describe-page/browser-page-types' + +export function useBrowserPageWebviewUrlSync({ + browserTabId, + browserTabUrl, + browserTabLoading, + isActive, + isPaintable, + slotViewport, + webviewRef, + chromeHeaderRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + keepAddressBarFocusRef, + addressBarInputRef, + browserTabUrlRef, + addressBarValueRef, + onUpdatePageStateRef, + focusWebviewNow +}: { + browserTabId: string + browserTabUrl: string + browserTabLoading: boolean + isActive: boolean + isPaintable: boolean + slotViewport: HTMLDivElement | null + webviewRef: MutableRefObject + chromeHeaderRef: RefObject + lastKnownWebviewUrlRef: MutableRefObject + trackNextLoadingEventRef: MutableRefObject + keepAddressBarFocusRef: MutableRefObject + addressBarInputRef: RefObject + browserTabUrlRef: MutableRefObject + addressBarValueRef: MutableRefObject + onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> + focusWebviewNow: () => boolean +}): void { + useLayoutEffect(() => { + applyBrowserPageViewportLayout(browserTabId, { paintable: isPaintable, active: isActive }) + const syncChromeInset = (): void => { + const header = chromeHeaderRef.current + if (!header) { + return + } + syncBrowserPageChromeInset(browserTabId, header.offsetHeight) + } + syncChromeInset() + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncChromeInset) + const header = chromeHeaderRef.current + if (header) { + resizeObserver?.observe(header) + } + return () => { + resizeObserver?.disconnect() + } + // Why: a replacement slot root needs visibility and chrome inset re-applied. + }, [browserTabId, chromeHeaderRef, isActive, isPaintable, slotViewport]) + + useEffect(() => { + const webview = webviewRef.current + if (!webview) { + return + } + const normalizedUrl = normalizeBrowserNavigationUrl(browserTabUrl) + if (!normalizedUrl) { + return + } + // Why: navigation events set both the store URL and this ref; a match means the change came from navigation, so skip to avoid a redirect infinite loop. + if (lastKnownWebviewUrlRef.current === normalizedUrl) { + return + } + let liveUrl: string | null = null + try { + liveUrl = webview.getURL() || null + } catch { + // Why: a newly attached guest can reject getURL(); skip so a transient error isn't misread as a mismatch and force-navigated. + return + } + const normalizedLiveUrl = liveUrl ? (normalizeBrowserNavigationUrl(liveUrl) ?? liveUrl) : null + const declaredSrc = webview.getAttribute('src') + if ( + normalizedLiveUrl !== normalizedUrl && + webview.src !== normalizedUrl && + declaredSrc !== normalizedUrl + ) { + // Why: browserTab.url changes are Orca-driven navigations; gate did-start-loading so only real navigations show loading UI. + trackNextLoadingEventRef.current = normalizedUrl !== ORCA_BROWSER_BLANK_URL + lastKnownWebviewUrlRef.current = normalizedUrl + webview.src = normalizedUrl + if (normalizedUrl !== ORCA_BROWSER_BLANK_URL) { + keepAddressBarFocusRef.current = false + if (document.activeElement === addressBarInputRef.current) { + focusWebviewNow() + } + } + } + }, [ + addressBarInputRef, + browserTabUrl, + focusWebviewNow, + keepAddressBarFocusRef, + lastKnownWebviewUrlRef, + trackNextLoadingEventRef, + webviewRef + ]) + + useEffect(() => { + if (!shouldPollChromiumErrorPage({ isActive, loading: browserTabLoading })) { + return + } + + const detectChromiumErrorPage = (): void => { + const webview = webviewRef.current + if (!webview) { + return + } + try { + const currentUrl = webview.getURL() || webview.src || '' + if (!isChromiumErrorPage(currentUrl)) { + return + } + + const attemptedUrl = browserTabUrlRef.current || addressBarValueRef.current || 'about:blank' + onUpdatePageStateRef.current(browserTabId, { + loading: false, + loadError: { + code: -1, + description: translate( + 'auto.components.browser.pane.BrowserPane.e48569ac6d', + 'This site could not be reached.' + ), + validatedUrl: redactKagiSessionToken(attemptedUrl) + } + }) + } catch { + // Why: ignore transient getURL() errors from a mid-attach guest; this poll is only a fallback. + } + } + + // Why: some Electron builds paint chrome-error pages without a did-fail-load event; poll only while the active tab loads as a fallback. + detectChromiumErrorPage() + const intervalId = window.setInterval(detectChromiumErrorPage, 250) + return () => window.clearInterval(intervalId) + }, [ + addressBarValueRef, + browserTabId, + browserTabLoading, + browserTabUrlRef, + isActive, + onUpdatePageStateRef, + webviewRef + ]) +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-frame-style.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-frame-style.test.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-frame-style.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.ts similarity index 93% rename from src/renderer/src/components/browser-pane/remote-browser-frame-style.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.ts index 91fb470e917..19014dff557 100644 --- a/src/renderer/src/components/browser-pane/remote-browser-frame-style.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from 'react' -import type { BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' +import type { BrowserScreencastFrameMetadata } from '../../../../../shared/browser-screencast-protocol' export function getRemoteBrowserFrameStyle( _metadata: BrowserScreencastFrameMetadata | null diff --git a/src/renderer/src/components/browser-pane/remote-browser-keyboard.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-keyboard.test.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-keyboard.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-keyboard.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.ts diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx new file mode 100644 index 00000000000..23ce8fb413b --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx @@ -0,0 +1,293 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { translate } from '@/i18n/i18n' +import { + normalizeExternalBrowserUrl, + redactKagiSessionToken +} from '../../../../../shared/browser-url' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { RemoteBrowserOperationToken } from './remote-browser-stream-tokens' +import { + buildRemoteContextMenuExpression, + readRemoteContextMenuResult, + type RemoteBrowserContextMenu, + type RemoteBrowserPaneNotice, + type RemoteBrowserRuntimeTarget +} from './remote-browser-page-input-model' + +export function useRemoteBrowserPageContextMenu({ + busy, + browserTabUrl, + imageRef, + runtimeTarget, + lifecycle, + runtimeWorktree, + getRemoteImagePoint, + enqueueRemoteInput, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + mountedRef, + setPaneNotice +}: { + busy: boolean + browserTabUrl: string + imageRef: React.RefObject + runtimeTarget: () => RemoteBrowserRuntimeTarget | null + lifecycle: RemoteBrowserStreamLifecycle + runtimeWorktree: string + getRemoteImagePoint: (event: { + clientX: number + clientY: number + }) => { x: number; y: number } | null + enqueueRemoteInput: (operation: () => Promise) => Promise + createRemoteOperationToken: (remotePageId?: string | null) => RemoteBrowserOperationToken | null + isCurrentRemoteOperationToken: (token: RemoteBrowserOperationToken) => boolean + closeMissingRemotePage: (remotePageId?: string | null) => void + mountedRef: React.RefObject + setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void +}): { + contextMenu: RemoteBrowserContextMenu | null + setContextMenu: React.Dispatch> + handleRemoteContextMenu: (event: React.MouseEvent) => void +} { + const [contextMenu, setContextMenu] = useState(null) + + useEffect(() => { + if (!contextMenu) { + return + } + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault() + setContextMenu(null) + } + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [contextMenu]) + + const handleRemoteContextMenu = (event: React.MouseEvent): void => { + if (busy) { + return + } + const target = runtimeTarget() + const pageId = lifecycle.tokens.remotePage + const point = getRemoteImagePoint(event) + if (!target || !pageId || !point) { + return + } + event.preventDefault() + imageRef.current?.focus() + setPaneNotice(null) + setContextMenu({ + x: event.clientX, + y: event.clientY, + linkUrl: null, + pageUrl: browserTabUrl || 'about:blank', + // Why: filled in below once the async eval reads the guest selection. + selectionText: '' + }) + enqueueRemoteInput(async () => { + const operationToken = createRemoteOperationToken(pageId) + if (!operationToken || !isCurrentRemoteOperationToken(operationToken)) { + return + } + try { + const result = await callRuntimeRpc( + target, + 'browser.eval', + { + worktree: runtimeWorktree, + page: pageId, + expression: buildRemoteContextMenuExpression(point.x, point.y) + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + const parsed = readRemoteContextMenuResult(result) + if (parsed && mountedRef.current && isCurrentRemoteOperationToken(operationToken)) { + setContextMenu((current) => + current + ? { + ...current, + linkUrl: parsed.linkUrl, + pageUrl: redactKagiSessionToken(parsed.pageUrl), + selectionText: parsed.selectionText + } + : current + ) + } + } catch (error) { + if ( + isCurrentRemoteOperationToken(operationToken) && + isRemoteBrowserPageMissingError(error) + ) { + closeMissingRemotePage(pageId) + } + // Keep the basic menu open even if element inspection is unavailable. + } + }) + } + + return { contextMenu, setContextMenu, handleRemoteContextMenu } +} + +export function RemoteBrowserPageContextMenu({ + contextMenu, + onDismiss, + onOpenLinkInOrcaBrowser, + onNavigate +}: { + contextMenu: RemoteBrowserContextMenu + onDismiss: () => void + onOpenLinkInOrcaBrowser: () => void + onNavigate: (method: 'browser.back' | 'browser.forward' | 'browser.reload') => void +}): React.JSX.Element { + const contextMenuRef = useRef(null) + + useLayoutEffect(() => { + const el = contextMenuRef.current + if (!el) { + return + } + el.style.left = `${contextMenu.x}px` + el.style.top = `${contextMenu.y}px` + const rect = el.getBoundingClientRect() + const offsetX = contextMenu.x - rect.left + const offsetY = contextMenu.y - rect.top + let renderX = contextMenu.x + let renderY = contextMenu.y + if (rect.right > window.innerWidth) { + renderX = contextMenu.x - rect.width + } + if (rect.bottom > window.innerHeight) { + renderY = contextMenu.y - rect.height + } + el.style.left = `${Math.max(0, renderX) + offsetX}px` + el.style.top = `${Math.max(0, renderY) + offsetY}px` + }, [contextMenu]) + + return createPortal( + <> +
+
+ {contextMenu.linkUrl ? ( + <> + + + +
+ + ) : null} + {contextMenu.selectionText.trim() ? ( + <> + +
+ + ) : null} + + + +
+ + +
+ , + document.body + ) +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts new file mode 100644 index 00000000000..656229c8f68 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + buildRemoteContextMenuExpression, + getPositiveFiniteNumber, + getRemoteBrowserMouseButton, + readRemoteContextMenuResult, + readRemoteCssViewportSize +} from './remote-browser-page-input-model' + +describe('remote browser page input model', () => { + it('maps mouse buttons and rejects unknown codes', () => { + expect(getRemoteBrowserMouseButton(0)).toBe('left') + expect(getRemoteBrowserMouseButton(1)).toBe('middle') + expect(getRemoteBrowserMouseButton(2)).toBe('right') + expect(getRemoteBrowserMouseButton(3)).toBeNull() + }) + + it('accepts only positive finite numbers', () => { + expect(getPositiveFiniteNumber(12)).toBe(12) + expect(getPositiveFiniteNumber(0)).toBeNull() + expect(getPositiveFiniteNumber(-1)).toBeNull() + expect(getPositiveFiniteNumber(Number.NaN)).toBeNull() + expect(getPositiveFiniteNumber('12')).toBeNull() + }) + + it('embeds coordinates in the guest context-menu expression', () => { + const expression = buildRemoteContextMenuExpression(10, 20) + expect(expression).toContain('10') + expect(expression).toContain('20') + expect(expression).toContain('elementFromPoint') + }) + + it('parses context-menu eval results and rejects junk', () => { + expect(readRemoteContextMenuResult(null)).toBeNull() + expect(readRemoteContextMenuResult({ result: 1 })).toBeNull() + expect( + readRemoteContextMenuResult({ + result: JSON.stringify({ + linkUrl: 'https://example.com', + pageUrl: 'https://example.com/page', + selectionText: 'hi' + }) + }) + ).toEqual({ + linkUrl: 'https://example.com', + pageUrl: 'https://example.com/page', + selectionText: 'hi' + }) + expect(readRemoteContextMenuResult({ result: JSON.stringify({ linkUrl: '' }) })).toEqual({ + linkUrl: null, + pageUrl: 'about:blank', + selectionText: '' + }) + }) + + it('parses CSS viewport sizes from eval results', () => { + expect( + readRemoteCssViewportSize({ result: JSON.stringify({ width: 800, height: 600 }) }) + ).toEqual({ width: 800, height: 600 }) + expect( + readRemoteCssViewportSize({ result: JSON.stringify({ width: 0, height: 600 }) }) + ).toBeNull() + expect(readRemoteCssViewportSize({ result: 'not-json' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.ts new file mode 100644 index 00000000000..8b89779c86d --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.ts @@ -0,0 +1,158 @@ +import type { BrowserTabInfo } from '../../../../../shared/runtime-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import type { + RemoteBrowserOperationToken, + RemoteBrowserStreamToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' + +export type RemoteBrowserRuntimeTarget = Extract + +export function decodeRemoteBrowserFrameUrl(url: string): Promise { + const image = new window.Image() + image.decoding = 'async' + image.src = url + if (typeof image.decode === 'function') { + return image.decode() + } + return new Promise((resolve, reject) => { + image.onload = () => resolve() + image.onerror = () => reject(new Error('Remote browser frame failed to decode.')) + }) +} + +export type RemoteBrowserContextMenu = { + x: number + y: number + linkUrl: string | null + pageUrl: string + selectionText: string +} + +export type RemoteBrowserPaneNotice = { + kind: 'direct' | 'consequence' + text: string +} + +export type RemoteBrowserImagePoint = { + x: number + y: number +} + +export type PendingRemoteBrowserWheel = { + target: RemoteBrowserRuntimeTarget + pageId: string + operationToken: RemoteBrowserOperationToken + point: RemoteBrowserImagePoint + dx: number + dy: number +} + +export const WHEEL_DELTA_LINE = 1 +export const WHEEL_DELTA_PAGE = 2 + +// The pane-owned effects the stream lifecycle calls back into: frame paint, viewport measurement, +// and the store/tab-close decision for a page that is gone. +export type RemoteBrowserStreamBridge = { + applyTabInfo: (tab: Pick) => void + clearFrame: () => void + handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array) => void + closeMissingRemotePage: (remotePageId: string | null) => void + waitForViewportSize: () => Promise + syncViewport: (pageId: string) => Promise +} + +export const NO_REMOTE_BROWSER_STREAM_BRIDGE: RemoteBrowserStreamBridge = { + applyTabInfo: () => {}, + clearFrame: () => {}, + handleFrameBytes: () => {}, + closeMissingRemotePage: () => {}, + waitForViewportSize: async () => null, + syncViewport: async () => {} +} + +export function getRemoteBrowserMouseButton(button: number): 'left' | 'middle' | 'right' | null { + if (button === 0) { + return 'left' + } + if (button === 1) { + return 'middle' + } + if (button === 2) { + return 'right' + } + return null +} + +export function buildRemoteContextMenuExpression(x: number, y: number): string { + return `(() => { + const target = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)}); + const anchor = target && typeof target.closest === 'function' ? target.closest('a[href]') : null; + // Why: read the guest selection here so the remote/paired browser can offer + // the same Copy affordance as the local webview (there is no ContextMenuParams + // over the runtime RPC). + const selection = typeof window.getSelection === 'function' ? window.getSelection() : null; + return JSON.stringify({ + linkUrl: anchor && anchor.href ? anchor.href : null, + pageUrl: location.href || 'about:blank', + selectionText: selection ? String(selection) : '' + }); + })()` +} + +export function readRemoteContextMenuResult( + result: unknown +): Pick | null { + if (!result || typeof result !== 'object') { + return null + } + const raw = (result as { result?: unknown }).result + if (typeof raw !== 'string') { + return null + } + try { + const parsed = JSON.parse(raw) as { + linkUrl?: unknown + pageUrl?: unknown + selectionText?: unknown + } + return { + linkUrl: typeof parsed.linkUrl === 'string' && parsed.linkUrl ? parsed.linkUrl : null, + pageUrl: + typeof parsed.pageUrl === 'string' && parsed.pageUrl ? parsed.pageUrl : 'about:blank', + selectionText: typeof parsed.selectionText === 'string' ? parsed.selectionText : '' + } + } catch { + return null + } +} + +export function readRemoteCssViewportSize(result: unknown): RemoteBrowserViewportSize | null { + if (!result || typeof result !== 'object') { + return null + } + const raw = (result as { result?: unknown }).result + if (typeof raw !== 'string') { + return null + } + try { + const parsed = JSON.parse(raw) as { width?: unknown; height?: unknown } + const width = getPositiveFiniteNumber(parsed.width) + const height = getPositiveFiniteNumber(parsed.height) + return width && height ? { width, height } : null + } catch { + return null + } +} + +export function getPositiveFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +export function getRemoteBrowserDeviceScaleFactor(): number { + if (typeof window === 'undefined') { + return 1 + } + const scale = Number.isFinite(window.devicePixelRatio) ? window.devicePixelRatio : 1 + return Math.min(2, Math.max(1, Number(scale.toFixed(2)))) +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx new file mode 100644 index 00000000000..aec1d901482 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx @@ -0,0 +1,352 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version' +import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { openWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-open' +import { useMarkupMode, type MarkupCaptureContext } from '../annotate/useMarkupMode' +import { deliverMarkupToClipboard } from '../annotate/markup-clipboard-delivery' +import { + isRemoteBrowserStreamBusy, + remoteBrowserStreamNotice +} from './remote-browser-stream-status' +import type { BrowserTabPageState, BrowserPageUrlSetter } from '../describe-page/browser-page-types' +import type { RemoteBrowserPaneNotice } from './remote-browser-page-input-model' +import { useRemoteBrowserPageLifecycle } from './use-remote-browser-page-lifecycle' +import { useRemoteBrowserPageStream } from './use-remote-browser-page-stream' +import { useRemoteBrowserPageNavigation } from './use-remote-browser-page-navigation' +import { + useRemoteBrowserPageInput, + useRemoteBrowserPageInputQueue +} from './use-remote-browser-page-input' +import { useRemoteBrowserPageWheel } from './use-remote-browser-page-wheel' +import { + RemoteBrowserPageContextMenu, + useRemoteBrowserPageContextMenu +} from './remote-browser-page-context-menu' +import { RemoteBrowserPageToolbar } from './remote-browser-page-toolbar' +import { RemoteBrowserPageViewport } from './remote-browser-page-viewport' + +export function RemoteBrowserPagePane({ + browserTab, + runtimeEnvironmentId, + worktreeId, + isActive, + onUpdatePageState, + onSetUrl +}: { + browserTab: BrowserPageState + runtimeEnvironmentId: string + worktreeId: string + isActive: boolean + onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + onSetUrl: BrowserPageUrlSetter +}): React.JSX.Element { + const activeRuntimeEnvironmentId = runtimeEnvironmentId + const addressBarInputRef = useRef(null) + const imageRef = useRef(null) + const remoteViewportRef = useRef(null) + // Pane-owned notices, split by what they are ABOUT, because that decides who outranks whom: + // + // 'direct' — feedback on what the user just did (URL validation). Always shown: it is the + // only response to their action, and suppressing it makes Enter look broken. + // 'consequence' — an operation that failed BECAUSE the stream is down (input, navigation RPCs). + // Outranked by the stream's own notice, which explains the cause; otherwise + // these repaint raw transport text over it on every stray click. + // + // Kept as one slot so the newest notice replaces the previous one, as a single toast should. + const [paneNotice, setPaneNotice] = useState(null) + const [paneBusy, setPaneBusy] = useState(false) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) + const remotePageHandle = useAppStore( + (s) => s.remoteBrowserPageHandlesByPageId[browserTab.id] ?? null + ) + + // Why: runtimes predating browser.certificate-trust.v1 can't honor a proceed request, so hide "Proceed Anyway" until support is advertised. + const [remoteCertificateTrustSupported, setRemoteCertificateTrustSupported] = useState(false) + const remoteCertificateEnvironmentId = remotePageHandle?.environmentId ?? null + const certificateChallengeId = certificateFailure?.challengeId ?? null + useEffect(() => { + if (!remoteCertificateEnvironmentId || !certificateChallengeId) { + setRemoteCertificateTrustSupported(false) + return + } + let cancelled = false + void runtimeEnvironmentSupportsCapability( + remoteCertificateEnvironmentId, + BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY + ) + .then((supported) => { + if (!cancelled) { + setRemoteCertificateTrustSupported(supported) + } + }) + .catch(() => { + if (!cancelled) { + setRemoteCertificateTrustSupported(false) + } + }) + return () => { + cancelled = true + } + }, [remoteCertificateEnvironmentId, certificateChallengeId]) + + const { + enqueueRemoteInput, + clearPendingRemoteWheel, + resetRemoteInputQueue, + pendingRemoteWheelRef, + remoteWheelFrameRef, + remoteWheelInFlightRef + } = useRemoteBrowserPageInputQueue() + + const { + lifecycle, + streamStatus, + frameUrl, + frameMetadata, + runtimeWorktree, + runtimeTarget, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + clearStreamFrame, + closeMissingRemotePage, + mountedRef, + isActiveRef, + streamBridgeRef, + streamFrameUrlRef, + pendingFrameDecodeRef, + remoteViewportSizeRef, + remoteCssViewportSizeRef, + remoteViewportTimerRef, + setFrameUrl, + setFrameMetadata + } = useRemoteBrowserPageLifecycle({ + browserTab, + worktreeId, + activeRuntimeEnvironmentId, + isActive, + setPaneNotice, + setPaneBusy, + clearPendingRemoteWheel, + resetRemoteInputQueue + }) + + // Derived, never stored. The stream's own notice wins over an incidental one: while the stream is + // down every input RPC fails as a matter of course, and those failures must not overwrite the + // message that explains why — nor can the reconnect control depend on one of them being present. + // A stopped stream delivers no frames that could clear paneBusy, so it must force busy off. + const busy = + streamStatus.kind === 'stopped' ? false : paneBusy || isRemoteBrowserStreamBusy(streamStatus) + const streamNotice = remoteBrowserStreamNotice(streamStatus) + const remoteError = + paneNotice?.kind === 'direct' ? paneNotice.text : (streamNotice ?? paneNotice?.text ?? null) + + const { + addressBarValue, + setAddressBarValue, + applyRemoteTabInfo, + scheduleRemoteTabInfoRefresh, + runRemoteNavigation, + navigateToUrl, + submitAddressBar + } = useRemoteBrowserPageNavigation({ + browserTab, + isActive, + addressBarInputRef, + imageRef, + remoteViewportRef, + lifecycle, + runtimeWorktree, + runtimeTarget, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + onSetUrl, + onUpdatePageState, + setPaneNotice, + setPaneBusy + }) + + const { reconnectRemoteStream } = useRemoteBrowserPageStream({ + activeRuntimeEnvironmentId, + browserPageId: browserTab.id, + isActive, + lifecycle, + runtimeWorktree, + runtimeTarget, + remoteViewportRef, + remoteViewportSizeRef, + remoteCssViewportSizeRef, + remoteViewportTimerRef, + streamFrameUrlRef, + pendingFrameDecodeRef, + streamBridgeRef, + isActiveRef, + applyTabInfo: applyRemoteTabInfo, + clearStreamFrame, + closeMissingRemotePage, + clearPendingRemoteWheel, + setPaneNotice, + setPaneBusy, + setFrameUrl, + setFrameMetadata + }) + + const { + getRemoteImagePoint, + handleRemotePointerDown, + handleRemotePointerUp, + handleRemoteScreenshotKeyDown + } = useRemoteBrowserPageInput({ + busy, + imageRef, + remoteViewportRef, + remoteCssViewportSizeRef, + remoteViewportSizeRef, + frameMetadata, + runtimeTarget, + lifecycle, + runtimeWorktree, + enqueueRemoteInput, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + scheduleRemoteTabInfoRefresh, + setPaneNotice + }) + + useRemoteBrowserPageWheel({ + busy, + imageRef, + remoteViewportRef, + frameUrl, + runtimeTarget, + lifecycle, + runtimeWorktree, + getRemoteImagePoint, + enqueueRemoteInput, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + scheduleRemoteTabInfoRefresh, + setPaneNotice, + pendingRemoteWheelRef, + remoteWheelFrameRef, + remoteWheelInFlightRef + }) + + const { contextMenu, setContextMenu, handleRemoteContextMenu } = useRemoteBrowserPageContextMenu({ + busy, + browserTabUrl: browserTab.url, + imageRef, + runtimeTarget, + lifecycle, + runtimeWorktree, + getRemoteImagePoint, + enqueueRemoteInput, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + mountedRef, + setPaneNotice + }) + + // Why: markup snapshots the displayed screencast (no injection), so it works on remote panes even though element-grab doesn't. + const markup = useMarkupMode({ + getCaptureContext: useCallback((): MarkupCaptureContext | null => { + const element = imageRef.current + const container = remoteViewportRef.current + if (!element || !container) { + return null + } + const rect = container.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + return null + } + return { + source: { kind: 'image', element }, + cssWidth: rect.width, + cssHeight: rect.height, + outputScale: window.devicePixelRatio || 1 + } + }, []), + onDeliver: deliverMarkupToClipboard + }) + + return ( + // The testid scopes E2E queries to this pane: a workspace can hold more than one browser pane, + // and controls like the address bar are otherwise ambiguous across them. +
+ {contextMenu ? ( + setContextMenu(null)} + onOpenLinkInOrcaBrowser={() => { + const linkUrl = contextMenu.linkUrl! + setContextMenu(null) + void openWorkspaceBrowserTab({ + workspaceId: worktreeId, + url: linkUrl, + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: runtimeEnvironmentId + }).catch((error) => { + setPaneNotice({ + kind: 'direct', + text: error instanceof Error ? error.message : String(error) + }) + }) + }} + onNavigate={(method) => { + void runRemoteNavigation(method) + setContextMenu(null) + }} + /> + ) : null} + void runRemoteNavigation('browser.back')} + onForward={() => void runRemoteNavigation('browser.forward')} + onReload={() => void runRemoteNavigation('browser.reload')} + /> + void runRemoteNavigation('browser.reload')} + onGoto={(url) => void runRemoteNavigation('browser.goto', url)} + onReconnect={reconnectRemoteStream} + handleRemotePointerDown={handleRemotePointerDown} + handleRemotePointerUp={handleRemotePointerUp} + handleRemoteContextMenu={handleRemoteContextMenu} + handleRemoteScreenshotKeyDown={handleRemoteScreenshotKeyDown} + /> +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-page-session.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts similarity index 97% rename from src/renderer/src/components/browser-pane/remote-browser-page-session.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts index 8cedddf81e4..c8def15b1d6 100644 --- a/src/renderer/src/components/browser-pane/remote-browser-page-session.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts @@ -1,6 +1,6 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' -import type { BrowserTabInfo } from '../../../../shared/runtime-types' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import type { BrowserTabInfo } from '../../../../../shared/runtime-types' import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' import type { RemoteBrowserOperationToken, diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx new file mode 100644 index 00000000000..b993b179f36 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx @@ -0,0 +1,112 @@ +import { ArrowLeft, ArrowRight, Loader2, MessageSquarePlus, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import BrowserAddressBar from '../assemble-chrome/BrowserAddressBar' +import { MarkupDrawButton } from '../annotate/MarkupDrawButton' +import type { MarkupModeController } from '../annotate/useMarkupMode' + +export function RemoteBrowserPageToolbar({ + addressBarValue, + onAddressBarChange, + onSubmitAddressBar, + onNavigateToUrl, + addressBarInputRef, + busy, + loading, + markup, + frameUrl, + isActive, + onBack, + onForward, + onReload +}: { + addressBarValue: string + onAddressBarChange: (value: string) => void + onSubmitAddressBar: () => void + onNavigateToUrl: (url: string) => void + addressBarInputRef: React.RefObject + busy: boolean + loading: boolean + markup: MarkupModeController + frameUrl: string | null + isActive: boolean + onBack: () => void + onForward: () => void + onReload: () => void +}): React.JSX.Element { + return ( +
+ + + {/* Why: no ignore-cache RPC exists for remote pages, and this pane binds no reload chord, so there is + nothing truthful to put in a menu or a shortcut hint here — tooltip only. */} + + + + + + {translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')} + + + + + + + + + {translate( + 'auto.components.browser.pane.BrowserPane.8b7e6d1f5a', + 'Browser annotations are only available in local browser tabs.' + )} + + + (markup.isActive ? markup.cancel() : void markup.start())} + disabled={!frameUrl} + active={markup.isActive} + surfaceActive={isActive} + className="h-7 w-7" + /> +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx new file mode 100644 index 00000000000..a58f9931bfb --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx @@ -0,0 +1,208 @@ +import { useMemo } from 'react' +import { Globe, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import type { + BrowserCertificateFailure, + BrowserCertificateProceedResult, + BrowserPage as BrowserPageState +} from '../../../../../shared/browser-workspace-types' +import type { RemoteBrowserPageHandle } from '@/store/slices/browser' +import { + resolveRemoteFailureExternalUrl, + toHttpsRecoveryUrl +} from '../../../../../shared/browser-url' +import type { BrowserScreencastFrameMetadata } from '../../../../../shared/browser-screencast-protocol' +import { getRemoteBrowserFrameStyle } from './remote-browser-frame-style' +import { MarkupOverlay } from '../annotate/MarkupOverlay' +import type { MarkupModeController } from '../annotate/useMarkupMode' +import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-overlay' +import { toDisplayUrl } from '../describe-page/browser-page-url-display' +import { + canReconnectRemoteBrowserStream, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RemoteBrowserRuntimeTarget } from './remote-browser-page-input-model' + +export function RemoteBrowserPageViewport({ + remoteViewportRef, + imageRef, + frameUrl, + frameMetadata, + busy, + markup, + browserTab, + remoteError, + streamStatus, + remoteCertificateTrustSupported, + certificateFailure, + remotePageHandle, + activeRuntimeEnvironmentId, + runtimeWorktree, + runtimeTarget, + onReload, + onGoto, + onReconnect, + handleRemotePointerDown, + handleRemotePointerUp, + handleRemoteContextMenu, + handleRemoteScreenshotKeyDown +}: { + remoteViewportRef: React.RefObject + imageRef: React.RefObject + frameUrl: string | null + frameMetadata: BrowserScreencastFrameMetadata | null + busy: boolean + markup: MarkupModeController + browserTab: BrowserPageState + remoteError: string | null + streamStatus: RemoteBrowserStreamStatus + remoteCertificateTrustSupported: boolean + certificateFailure: BrowserCertificateFailure | null + remotePageHandle: RemoteBrowserPageHandle | null + activeRuntimeEnvironmentId: string + runtimeWorktree: string + runtimeTarget: () => RemoteBrowserRuntimeTarget | null + onReload: () => void + onGoto: (url: string) => void + onReconnect: () => void + handleRemotePointerDown: (event: React.PointerEvent) => void + handleRemotePointerUp: (event: React.PointerEvent) => void + handleRemoteContextMenu: (event: React.MouseEvent) => void + handleRemoteScreenshotKeyDown: (event: React.KeyboardEvent) => void +}): React.JSX.Element { + const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata]) + const remoteFailureUrl = browserTab.loadError?.validatedUrl ?? browserTab.url + const remoteFailureExternalUrl = resolveRemoteFailureExternalUrl(remoteFailureUrl) + const showRemoteFailureOverlay = + Boolean(browserTab.loadError) && + remoteFailureUrl !== 'about:blank' && + remoteFailureUrl !== ORCA_BROWSER_BLANK_URL + + return ( +
+ {markup.isActive && markup.baseImage ? ( + void markup.complete(input)} + onCancel={markup.cancel} + /> + ) : null} + {frameUrl ? ( + + ) : ( +
+
+ {busy ? ( + + ) : ( + + )} +
+ {busy + ? translate( + 'auto.components.browser.pane.BrowserPane.b313a7275b', + 'Opening remote browser' + ) + : translate( + 'auto.components.browser.pane.BrowserPane.572046436a', + 'Remote browser' + )} +
+
+ {translate( + 'auto.components.browser.pane.BrowserPane.bbe8f15e83', + 'This pane is rendered from the active runtime server.' + )} +
+
+
+ )} + {showRemoteFailureOverlay && browserTab.loadError ? ( + void window.api.ui.writeClipboardText(url)} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={remoteCertificateTrustSupported ? certificateFailure : null} + expectedBrowserPageId={ + remotePageHandle?.environmentId === activeRuntimeEnvironmentId + ? remotePageHandle.remotePageId + : null + } + onProceedCertificate={async (challengeId) => { + const target = runtimeTarget() + if ( + !target || + remotePageHandle?.environmentId !== target.environmentId || + remotePageHandle.remotePageId !== certificateFailure?.browserPageId + ) { + return { ok: false, reason: 'missing' } + } + return callRuntimeRpc( + target, + 'browser.certificate.proceed', + { + worktree: runtimeWorktree, + page: remotePageHandle.remotePageId, + challengeId + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + }} + /> + ) : null} + {/* Why the reconnect control also opens this toast: the control renders inside it, so + gating the toast on the message alone made an empty message ('' from a host that failed + with no text) swallow the user's only way back — the original stranding bug, reachable + through three sites that forward host-supplied text into `stopped`. */} + {remoteError || canReconnectRemoteBrowserStream(streamStatus) ? ( +
+ {remoteError} + {canReconnectRemoteBrowserStream(streamStatus) ? ( + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-screencast-subscription.ts similarity index 93% rename from src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-screencast-subscription.ts index 9968722864f..978cec7bc68 100644 --- a/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-screencast-subscription.ts @@ -1,9 +1,9 @@ -import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeRpcResponse } from '../../../../../shared/runtime-rpc-envelope' import type { BrowserScreencastReadyResult, BrowserScreencastResult -} from '../../../../shared/runtime-types' -import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' +} from '../../../../../shared/runtime-types' +import { withBrowserPaneUiRuntimeRpcSource } from '../../../../../shared/runtime-rpc-feature-interaction-source' import { isRemoteBrowserPageMissingCode } from './remote-browser-stream-errors' import type { RemoteBrowserViewportSize } from './remote-browser-stream-tokens' diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-deps.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-deps.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-test-harness.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-test-harness.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts similarity index 99% rename from src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts index dd31353db51..8f77bf93e50 100644 --- a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts @@ -5,7 +5,7 @@ import { rpcError, settle } from './remote-browser-stream-lifecycle-test-harness' -import { isBrowserPaneUiRuntimeRpcParams } from '../../../../shared/runtime-rpc-feature-interaction-source' +import { isBrowserPaneUiRuntimeRpcParams } from '../../../../../shared/runtime-rpc-feature-interaction-source' describe('RemoteBrowserStreamLifecycle', () => { beforeEach(() => { diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts similarity index 99% rename from src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts index 30ce057690d..c32929b2e1d 100644 --- a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts @@ -1,5 +1,5 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { RuntimeStatus } from '../../../../../shared/runtime-types' import { RemoteBrowserPageSession } from './remote-browser-page-session' import { openRemoteBrowserScreencastStream } from './remote-browser-screencast-subscription' import { RemoteBrowserStreamRestartScheduler } from './remote-browser-stream-restart-scheduler' diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-liveness.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-liveness.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts similarity index 97% rename from src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts index 648dcf52d8d..ac129875a9e 100644 --- a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts @@ -14,7 +14,7 @@ import { type RemoteBrowserStreamSubscription, type RemoteBrowserStreamToken } from './remote-browser-stream-tokens' -import type { BrowserTabInfo } from '../../../../shared/runtime-types' +import type { BrowserTabInfo } from '../../../../../shared/runtime-types' export type RemoteBrowserStreamRestartAttemptDeps = { tokens: RemoteBrowserOperationTokens diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-status.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-status.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.ts diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-tokens.ts similarity index 100% rename from src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts rename to src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-tokens.ts diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts new file mode 100644 index 00000000000..e514f77d4dd --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts @@ -0,0 +1,296 @@ +import { useCallback, useRef } from 'react' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isEditableKeyboardTarget } from '../host-guest/browser-keyboard' +import { + getRemoteBrowserKeyboardShortcut, + getRemoteBrowserKeypressKey +} from './remote-browser-keyboard' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { + RemoteBrowserOperationToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' +import type { BrowserScreencastFrameMetadata } from '../../../../../shared/browser-screencast-protocol' +import { + getPositiveFiniteNumber, + getRemoteBrowserMouseButton, + type PendingRemoteBrowserWheel, + type RemoteBrowserPaneNotice, + type RemoteBrowserRuntimeTarget +} from './remote-browser-page-input-model' + +export function useRemoteBrowserPageInputQueue(): { + enqueueRemoteInput: (operation: () => Promise) => Promise + clearPendingRemoteWheel: () => void + resetRemoteInputQueue: () => void + pendingRemoteWheelRef: React.MutableRefObject + remoteWheelFrameRef: React.MutableRefObject + remoteWheelInFlightRef: React.MutableRefObject +} { + const remoteInputQueueRef = useRef>(Promise.resolve()) + const pendingRemoteWheelRef = useRef(null) + const remoteWheelFrameRef = useRef(null) + const remoteWheelInFlightRef = useRef(false) + + const enqueueRemoteInput = useCallback((operation: () => Promise): Promise => { + const next = remoteInputQueueRef.current.catch(() => {}).then(operation) + remoteInputQueueRef.current = next.catch(() => {}) + return next + }, []) + + const resetRemoteInputQueue = useCallback((): void => { + remoteInputQueueRef.current = Promise.resolve() + }, []) + + const clearPendingRemoteWheel = useCallback((): void => { + pendingRemoteWheelRef.current = null + remoteWheelInFlightRef.current = false + if (remoteWheelFrameRef.current !== null) { + window.cancelAnimationFrame(remoteWheelFrameRef.current) + remoteWheelFrameRef.current = null + } + }, []) + + return { + enqueueRemoteInput, + clearPendingRemoteWheel, + resetRemoteInputQueue, + pendingRemoteWheelRef, + remoteWheelFrameRef, + remoteWheelInFlightRef + } +} + +export function useRemoteBrowserPageInput({ + busy, + imageRef, + remoteViewportRef, + remoteCssViewportSizeRef, + remoteViewportSizeRef, + frameMetadata, + runtimeTarget, + lifecycle, + runtimeWorktree, + enqueueRemoteInput, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + scheduleRemoteTabInfoRefresh, + setPaneNotice +}: { + busy: boolean + imageRef: React.RefObject + remoteViewportRef: React.RefObject + remoteCssViewportSizeRef: React.MutableRefObject + remoteViewportSizeRef: React.MutableRefObject + frameMetadata: BrowserScreencastFrameMetadata | null + runtimeTarget: () => RemoteBrowserRuntimeTarget | null + lifecycle: RemoteBrowserStreamLifecycle + runtimeWorktree: string + enqueueRemoteInput: (operation: () => Promise) => Promise + createRemoteOperationToken: (remotePageId?: string | null) => RemoteBrowserOperationToken | null + isCurrentRemoteOperationToken: (token: RemoteBrowserOperationToken) => boolean + closeMissingRemotePage: (remotePageId?: string | null) => void + scheduleRemoteTabInfoRefresh: (token: RemoteBrowserOperationToken, delayMs?: number) => void + setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void +}): { + getRemoteImagePoint: (event: { + clientX: number + clientY: number + }) => { x: number; y: number } | null + handleRemotePointerDown: (event: React.PointerEvent) => void + handleRemotePointerUp: (event: React.PointerEvent) => void + handleRemoteScreenshotKeyDown: (event: React.KeyboardEvent) => void +} { + const getRemoteImagePoint = useCallback( + (event: { clientX: number; clientY: number }): { x: number; y: number } | null => { + const image = imageRef.current + const viewport = remoteViewportRef.current + if (!image || !viewport) { + return null + } + const rect = viewport.getBoundingClientRect() + const viewportWidth = + getPositiveFiniteNumber(remoteCssViewportSizeRef.current?.width) ?? + getPositiveFiniteNumber(remoteViewportSizeRef.current?.width) ?? + getPositiveFiniteNumber(frameMetadata?.deviceWidth) ?? + image.naturalWidth + const viewportHeight = + getPositiveFiniteNumber(remoteCssViewportSizeRef.current?.height) ?? + getPositiveFiniteNumber(remoteViewportSizeRef.current?.height) ?? + getPositiveFiniteNumber(frameMetadata?.deviceHeight) ?? + image.naturalHeight + if (rect.width <= 0 || rect.height <= 0 || viewportWidth <= 0 || viewportHeight <= 0) { + return null + } + return { + x: Math.round(((event.clientX - rect.left) / rect.width) * viewportWidth), + y: Math.round(((event.clientY - rect.top) / rect.height) * viewportHeight) + } + }, + [frameMetadata, imageRef, remoteCssViewportSizeRef, remoteViewportRef, remoteViewportSizeRef] + ) + + const handleRemotePointerDown = (event: React.PointerEvent): void => { + if (busy) { + return + } + const target = runtimeTarget() + const pageId = lifecycle.tokens.remotePage + const image = imageRef.current + const operationToken = pageId ? createRemoteOperationToken(pageId) : null + const point = getRemoteImagePoint(event) + const button = getRemoteBrowserMouseButton(event.button) + if (button === 'right') { + return + } + if (!target || !pageId || !image || !operationToken || !point || !button) { + return + } + event.preventDefault() + image.focus() + setPaneNotice(null) + enqueueRemoteInput(async () => { + if (!isCurrentRemoteOperationToken(operationToken)) { + return + } + try { + const params = { worktree: runtimeWorktree, page: pageId } + await callRuntimeRpc( + target, + 'browser.mouseMove', + { ...params, x: point.x, y: point.y }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + await callRuntimeRpc( + target, + 'browser.mouseDown', + { ...params, button }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + } catch (error) { + if (isCurrentRemoteOperationToken(operationToken)) { + if (isRemoteBrowserPageMissingError(error)) { + closeMissingRemotePage(pageId) + return + } + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote mouse input failed.' + }) + } + } + }) + } + + const handleRemotePointerUp = (event: React.PointerEvent): void => { + if (busy) { + return + } + const target = runtimeTarget() + const pageId = lifecycle.tokens.remotePage + const operationToken = pageId ? createRemoteOperationToken(pageId) : null + const point = getRemoteImagePoint(event) + const button = getRemoteBrowserMouseButton(event.button) + if (button === 'right') { + return + } + if (!target || !pageId || !operationToken || !point || !button) { + return + } + event.preventDefault() + setPaneNotice(null) + enqueueRemoteInput(async () => { + if (!isCurrentRemoteOperationToken(operationToken)) { + return + } + try { + const params = { worktree: runtimeWorktree, page: pageId } + await callRuntimeRpc( + target, + 'browser.mouseMove', + { ...params, x: point.x, y: point.y }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + await callRuntimeRpc( + target, + 'browser.mouseUp', + { ...params, button }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + scheduleRemoteTabInfoRefresh(operationToken, 250) + } catch (error) { + if (isCurrentRemoteOperationToken(operationToken)) { + if (isRemoteBrowserPageMissingError(error)) { + closeMissingRemotePage(pageId) + return + } + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote mouse input failed.' + }) + } + } + }) + } + + const handleRemoteScreenshotKeyDown = (event: React.KeyboardEvent): void => { + if (isEditableKeyboardTarget(event.target)) { + return + } + const target = runtimeTarget() + const pageId = lifecycle.tokens.remotePage + const operationToken = pageId ? createRemoteOperationToken(pageId) : null + if (!target || !pageId || !operationToken) { + return + } + const params = { worktree: runtimeWorktree, page: pageId } + const key = getRemoteBrowserKeyboardShortcut(event) ?? getRemoteBrowserKeypressKey(event) + if (!key) { + return + } + event.preventDefault() + setPaneNotice(null) + enqueueRemoteInput(async () => { + if (!isCurrentRemoteOperationToken(operationToken)) { + return + } + try { + await callRuntimeRpc( + target, + 'browser.keypress', + { ...params, key }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + if ( + key === 'Enter' || + key === 'Meta+r' || + key === 'Meta+Shift+r' || + key === 'Control+r' || + key === 'Control+Shift+r' + ) { + scheduleRemoteTabInfoRefresh(operationToken, 400) + } + } catch (error) { + if (isCurrentRemoteOperationToken(operationToken)) { + if (isRemoteBrowserPageMissingError(error)) { + closeMissingRemotePage(pageId) + return + } + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote keyboard input failed.' + }) + } + } + }) + } + + return { + getRemoteImagePoint, + handleRemotePointerDown, + handleRemotePointerUp, + handleRemoteScreenshotKeyDown + } +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts new file mode 100644 index 00000000000..78cff819448 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { + RemoteBrowserOperationToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { BrowserScreencastFrameMetadata } from '../../../../../shared/browser-screencast-protocol' +import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' +import { browserPageExists } from '../describe-page/browser-page-load-error' +import { + REMOTE_BROWSER_STREAM_IDLE, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' +import { + getRemoteBrowserDeviceScaleFactor, + NO_REMOTE_BROWSER_STREAM_BRIDGE, + type RemoteBrowserPaneNotice, + type RemoteBrowserStreamBridge +} from './remote-browser-page-input-model' + +export function useRemoteBrowserPageLifecycle({ + browserTab, + worktreeId, + activeRuntimeEnvironmentId, + isActive, + setPaneNotice, + setPaneBusy, + clearPendingRemoteWheel, + resetRemoteInputQueue +}: { + browserTab: BrowserPageState + worktreeId: string + activeRuntimeEnvironmentId: string + isActive: boolean + setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void + setPaneBusy: (busy: boolean) => void + clearPendingRemoteWheel: () => void + resetRemoteInputQueue: () => void +}) { + const [frameUrl, setFrameUrl] = useState(null) + const [frameMetadata, setFrameMetadata] = useState(null) + const setFrameUrlRef = useRef(setFrameUrl) + const setFrameMetadataRef = useRef(setFrameMetadata) + // The single source for what the stream is doing. busy, the notice, and whether the reconnect + // control renders are all derived below, so they cannot disagree — see + // remote-browser-stream-status.ts for the four ways they used to. + const [streamStatus, setStreamStatus] = useState( + REMOTE_BROWSER_STREAM_IDLE + ) + const remoteViewportSizeRef = useRef(null) + const remoteCssViewportSizeRef = useRef(null) + const remoteViewportTimerRef = useRef(null) + const streamFrameUrlRef = useRef(null) + const pendingFrameDecodeRef = useRef(0) + const mountedRef = useRef(true) + const isActiveRef = useRef(isActive) + const currentBrowserTabIdRef = useRef(browserTab.id) + const currentBrowserTabUrlRef = useRef(browserTab.url) + const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId]) + const runtimeWorktreeRef = useRef(runtimeWorktree) + const activeRuntimeEnvironmentIdRef = useRef(activeRuntimeEnvironmentId) + // Why: the stream lifecycle is built once per pane, before the callbacks it needs exist. It + // reaches them through this bridge so it never captures a render's stale closure. + const streamBridgeRef = useRef(NO_REMOTE_BROWSER_STREAM_BRIDGE) + const lifecycleRef = useRef(null) + const closeBrowserPage = useAppStore((s) => s.closeBrowserPage) + const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) + + useLayoutEffect(() => { + currentBrowserTabIdRef.current = browserTab.id + currentBrowserTabUrlRef.current = browserTab.url + activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId + isActiveRef.current = isActive + runtimeWorktreeRef.current = runtimeWorktree + }, [activeRuntimeEnvironmentId, browserTab.id, browserTab.url, isActive, runtimeWorktree]) + + if (!lifecycleRef.current) { + lifecycleRef.current = new RemoteBrowserStreamLifecycle({ + identity: { + isMounted: () => mountedRef.current, + isActive: () => isActiveRef.current, + getTabId: () => currentBrowserTabIdRef.current, + getEnvironmentId: () => activeRuntimeEnvironmentIdRef.current, + browserPageExists + }, + callRpc: callRuntimeRpc, + subscribeScreencast: (args, callbacks) => + window.api.runtimeEnvironments.subscribe(args, callbacks), + getWorktreeSelector: () => runtimeWorktreeRef.current, + getCurrentUrl: () => currentBrowserTabUrlRef.current, + readStoredHandle: () => + useAppStore.getState().remoteBrowserPageHandlesByPageId[currentBrowserTabIdRef.current] ?? + null, + writeStoredHandle: (handle) => + useAppStore.getState().setRemoteBrowserPageHandle(currentBrowserTabIdRef.current, handle), + removeStoredHandle: (remotePageId) => { + useAppStore + .getState() + .removeRemoteBrowserPageHandle(currentBrowserTabIdRef.current, remotePageId) + }, + getDeviceScaleFactor: getRemoteBrowserDeviceScaleFactor, + setStatus: (status) => { + setStreamStatus(status) + // Why every status change clears the pane's notice, not just the recovering ones: a pane + // notice describes the situation the PREVIOUS status described, so any transition makes it + // stale. Clearing only on live/opening left a 'direct' notice — which outranks the stream's + // own — on screen after the stream stopped, so a stranded pane showed "Enter a valid http(s) + // or localhost URL." beside its Reconnect button and never showed the actual cause. + // It also has no other owner: nothing else would dismiss it. + setPaneNotice(null) + }, + applyTabInfo: (tab) => streamBridgeRef.current.applyTabInfo(tab), + clearFrame: () => streamBridgeRef.current.clearFrame(), + handleFrameBytes: (token, bytes) => streamBridgeRef.current.handleFrameBytes(token, bytes), + closeMissingRemotePage: (remotePageId) => + streamBridgeRef.current.closeMissingRemotePage(remotePageId), + waitForViewportSize: () => streamBridgeRef.current.waitForViewportSize(), + readViewportSize: () => remoteViewportSizeRef.current, + syncViewport: (pageId) => streamBridgeRef.current.syncViewport(pageId) + }) + } + const lifecycle = lifecycleRef.current + + const runtimeTarget = useCallback(() => { + return activeRuntimeEnvironmentId + ? ({ + kind: 'environment', + environmentId: activeRuntimeEnvironmentId + } satisfies RuntimeClientTarget) + : null + }, [activeRuntimeEnvironmentId]) + + const clearStreamFrame = useCallback((): void => { + pendingFrameDecodeRef.current += 1 + const prevUrl = streamFrameUrlRef.current + streamFrameUrlRef.current = null + remoteCssViewportSizeRef.current = null + lifecycle.forgetStreamViewportSize() + setFrameMetadataRef.current(null) + setFrameUrlRef.current(null) + if (prevUrl) { + URL.revokeObjectURL(prevUrl) + } + }, [lifecycle]) + + const closeMissingRemotePage = useCallback( + (remotePageId: string | null = lifecycle.tokens.remotePage): void => { + const state = useAppStore.getState() + if (remotePageId) { + state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) + } + lifecycle.abandonRemotePage() + if (remoteViewportTimerRef.current !== null) { + window.clearTimeout(remoteViewportTimerRef.current) + remoteViewportTimerRef.current = null + } + resetRemoteInputQueue() + clearStreamFrame() + setPaneNotice(null) + setPaneBusy(false) + // Why: a runtime-side tab close mirrors closing the visible tab; don't leave a dead pane behind. + const workspacePageCount = state.browserPagesByWorkspace[browserTab.workspaceId]?.length ?? 0 + if (workspacePageCount <= 1) { + closeBrowserTab(browserTab.workspaceId) + return + } + closeBrowserPage(browserTab.id) + }, + [ + browserTab.id, + browserTab.workspaceId, + clearStreamFrame, + closeBrowserPage, + closeBrowserTab, + lifecycle, + resetRemoteInputQueue, + setPaneBusy, + setPaneNotice + ] + ) + + const createRemoteOperationToken = useCallback( + (remotePageId: string | null = null): RemoteBrowserOperationToken | null => + lifecycle.tokens.createOperationToken(remotePageId), + [lifecycle] + ) + + const isCurrentRemoteOperationToken = useCallback( + (token: RemoteBrowserOperationToken): boolean => lifecycle.tokens.isCurrent(token), + [lifecycle] + ) + + useEffect(() => { + // Why: StrictMode's mount→cleanup→mount leaves mountedRef false; re-arm or operation tokens read stale and the pane wedges. + mountedRef.current = true + return () => { + mountedRef.current = false + pendingFrameDecodeRef.current += 1 + lifecycle.dispose() + if (remoteViewportTimerRef.current !== null) { + window.clearTimeout(remoteViewportTimerRef.current) + remoteViewportTimerRef.current = null + } + clearPendingRemoteWheel() + if (streamFrameUrlRef.current) { + URL.revokeObjectURL(streamFrameUrlRef.current) + streamFrameUrlRef.current = null + } + } + }, [clearPendingRemoteWheel, lifecycle]) + + useEffect(() => { + // Why: only reset frame/wheel on identity change; bumping the stream/operation generations here races the streaming effect and wedges the pane. + lifecycle.forgetStreamViewportSize() + clearPendingRemoteWheel() + clearStreamFrame() + }, [ + activeRuntimeEnvironmentId, + browserTab.id, + clearPendingRemoteWheel, + clearStreamFrame, + lifecycle + ]) + + useEffect(() => { + if (!activeRuntimeEnvironmentId) { + return + } + return () => { + const remotePageId = lifecycle.tokens.remotePage + if (!remotePageId) { + return + } + const state = useAppStore.getState() + const currentEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + const pageStillExists = browserPageExists(browserTab.id) + if (currentEnvironmentId === activeRuntimeEnvironmentId && pageStillExists) { + return + } + const removedHandle = state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) + lifecycle.tokens.setRemotePage(null) + if (!removedHandle) { + return + } + // Why: remote tabs outlive React components on the daemon; close only when the local page or its runtime environment is gone. + void callRuntimeRpc( + { kind: 'environment', environmentId: removedHandle.environmentId }, + 'browser.tabClose', + { worktree: runtimeWorktree, page: removedHandle.remotePageId }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ).catch(() => {}) + } + }, [activeRuntimeEnvironmentId, browserTab.id, lifecycle, runtimeWorktree, worktreeId]) + + return { + lifecycle, + streamStatus, + frameUrl, + frameMetadata, + runtimeWorktree, + runtimeTarget, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + clearStreamFrame, + closeMissingRemotePage, + mountedRef, + isActiveRef, + streamBridgeRef, + streamFrameUrlRef, + pendingFrameDecodeRef, + remoteViewportSizeRef, + remoteCssViewportSizeRef, + remoteViewportTimerRef, + setFrameUrl, + setFrameMetadata + } +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts new file mode 100644 index 00000000000..51465b692fc --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppStore } from '@/store' +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../../shared/browser-url' +import { keybindingMatchesAction } from '../../../../../shared/keybindings' +import type { + BrowserBackResult, + BrowserGotoResult, + BrowserReloadResult, + BrowserTabInfo +} from '../../../../../shared/runtime-types' +import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' +import { getShortcutPlatform } from '@/hooks/useShortcutLabel' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { RemoteBrowserOperationToken } from './remote-browser-stream-tokens' +import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import { + consumeBrowserFocusRequest, + ORCA_BROWSER_FOCUS_REQUEST_EVENT, + type BrowserFocusRequestDetail +} from '../host-guest/browser-focus' +import type { BrowserPageUrlSetter, BrowserTabPageState } from '../describe-page/browser-page-types' +import { getBrowserDisplayTitle, toDisplayUrl } from '../describe-page/browser-page-url-display' +import type { + RemoteBrowserPaneNotice, + RemoteBrowserRuntimeTarget +} from './remote-browser-page-input-model' + +export function useRemoteBrowserPageNavigation({ + browserTab, + isActive, + addressBarInputRef, + imageRef, + remoteViewportRef, + lifecycle, + runtimeWorktree, + runtimeTarget, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + onSetUrl, + onUpdatePageState, + setPaneNotice, + setPaneBusy +}: { + browserTab: BrowserPageState + isActive: boolean + addressBarInputRef: React.RefObject + imageRef: React.RefObject + remoteViewportRef: React.RefObject + lifecycle: RemoteBrowserStreamLifecycle + runtimeWorktree: string + runtimeTarget: () => RemoteBrowserRuntimeTarget | null + createRemoteOperationToken: (remotePageId?: string | null) => RemoteBrowserOperationToken | null + isCurrentRemoteOperationToken: (token: RemoteBrowserOperationToken) => boolean + closeMissingRemotePage: (remotePageId?: string | null) => void + onSetUrl: BrowserPageUrlSetter + onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void + setPaneBusy: (busy: boolean) => void +}): { + addressBarValue: string + setAddressBarValue: (value: string) => void + applyRemoteTabInfo: (tab: Pick) => void + scheduleRemoteTabInfoRefresh: (token: RemoteBrowserOperationToken, delayMs?: number) => void + runRemoteNavigation: ( + method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload', + url?: string + ) => Promise + navigateToUrl: (url: string) => void + submitAddressBar: () => void +} { + const [addressBarValue, setAddressBarValue] = useState(toDisplayUrl(browserTab.url)) + const keybindings = useAppStore((state) => state.keybindings) + + useEffect(() => { + if (document.activeElement === addressBarInputRef.current) { + return + } + setAddressBarValue(toDisplayUrl(browserTab.url)) + }, [addressBarInputRef, browserTab.url]) + + const applyRemoteTabInfo = useCallback( + (tab: Pick): void => { + const safeUrl = redactKagiSessionToken(tab.url || 'about:blank') + onSetUrl(browserTab.id, safeUrl) + onUpdatePageState(browserTab.id, { + title: getBrowserDisplayTitle(tab.title, safeUrl), + loading: false, + loadError: null + }) + if (document.activeElement !== addressBarInputRef.current) { + setAddressBarValue(toDisplayUrl(safeUrl)) + } + }, + [addressBarInputRef, browserTab.id, onSetUrl, onUpdatePageState] + ) + + const scheduleRemoteTabInfoRefresh = useCallback( + (token: RemoteBrowserOperationToken, delayMs = 250): void => { + lifecycle.session.scheduleTabInfoRefresh(token, delayMs) + }, + [lifecycle] + ) + + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onFocusBrowserAddressBar(() => { + addressBarInputRef.current?.focus() + addressBarInputRef.current?.select() + }) + }, [addressBarInputRef, isActive]) + + useEffect(() => { + if (!isActive) { + return + } + const handleBrowserFocusRequest = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail || detail.pageId !== browserTab.id) { + return + } + const focusTarget = consumeBrowserFocusRequest(browserTab.id) + if (!focusTarget) { + return + } + if (focusTarget === 'address-bar') { + addressBarInputRef.current?.focus() + addressBarInputRef.current?.select() + return + } + const target = imageRef.current ?? remoteViewportRef.current + target?.focus() + } + window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest) + return () => + window.removeEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest) + }, [addressBarInputRef, browserTab.id, imageRef, isActive, remoteViewportRef]) + + const runRemoteNavigation = useCallback( + async ( + method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload', + url?: string + ) => { + const target = runtimeTarget() + if (!target) { + return + } + const operationToken = createRemoteOperationToken() + if (!operationToken) { + return + } + const pageId = await lifecycle.session.ensureRemotePage(operationToken) + if (!pageId) { + return + } + const pageToken = { ...operationToken, remotePageId: pageId } + if (!isCurrentRemoteOperationToken(pageToken)) { + return + } + setPaneBusy(true) + setPaneNotice(null) + onUpdatePageState(browserTab.id, { loading: true, loadError: null }) + try { + const params = + method === 'browser.goto' + ? { worktree: runtimeWorktree, page: pageId, url: url ?? 'about:blank' } + : { worktree: runtimeWorktree, page: pageId } + const result = await callRuntimeRpc< + BrowserGotoResult | BrowserBackResult | BrowserReloadResult + >(target, method, params, { timeoutMs: 30_000, suppressFeatureInteraction: true }) + if (isCurrentRemoteOperationToken(pageToken)) { + applyRemoteTabInfo(result) + } + } catch (error) { + if (!isCurrentRemoteOperationToken(pageToken)) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + closeMissingRemotePage(pageId) + return + } + const message = error instanceof Error ? error.message : 'Remote browser command failed.' + setPaneNotice({ kind: 'consequence', text: message }) + onUpdatePageState(browserTab.id, { + loading: false, + // Why: validatedUrl is persisted, so redact the Kagi session token like the main-process failure path does. + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(url ?? browserTab.url) + } + }) + } finally { + if (isCurrentRemoteOperationToken(pageToken)) { + setPaneBusy(false) + } + } + }, + [ + applyRemoteTabInfo, + browserTab.id, + browserTab.url, + createRemoteOperationToken, + lifecycle, + closeMissingRemotePage, + isCurrentRemoteOperationToken, + onUpdatePageState, + runtimeTarget, + runtimeWorktree, + setPaneBusy, + setPaneNotice + ] + ) + + const navigateToUrl = useCallback( + (url: string): void => { + void runRemoteNavigation('browser.goto', url) + }, + [runRemoteNavigation] + ) + + // Browser history shortcuts for SSH/runtime browsers. + // Why: remote panes have no local webview ref, so route history through runtime RPC instead of WebContents. + useEffect(() => { + if (!isActive) { + return + } + const shortcutPlatform = getShortcutPlatform() + const handleKeyDown = (e: KeyboardEvent): void => { + const method = keybindingMatchesAction('browser.back', e, shortcutPlatform, keybindings) + ? 'browser.back' + : keybindingMatchesAction('browser.forward', e, shortcutPlatform, keybindings) + ? 'browser.forward' + : null + if (method === null) { + return + } + e.preventDefault() + e.stopPropagation() + void runRemoteNavigation(method) + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [isActive, keybindings, runRemoteNavigation]) + + const submitAddressBar = (): void => { + const searchEngine = useAppStore.getState().browserDefaultSearchEngine + const kagiSessionLink = useAppStore.getState().browserKagiSessionLink + const nextUrl = normalizeBrowserNavigationUrl(addressBarValue, searchEngine, { + kagiSessionLink + }) + if (!nextUrl) { + const message = 'Enter a valid http(s) or localhost URL.' + // 'direct': the only response to what the user just typed. With an empty address bar no + // load-error overlay renders either, so outranking this would make Enter do nothing visible. + setPaneNotice({ kind: 'direct', text: message }) + onUpdatePageState(browserTab.id, { + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(addressBarValue.trim()) || 'about:blank' + } + }) + return + } + navigateToUrl(nextUrl) + } + + return { + addressBarValue, + setAddressBarValue, + applyRemoteTabInfo, + scheduleRemoteTabInfoRefresh, + runRemoteNavigation, + navigateToUrl, + submitAddressBar + } +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts new file mode 100644 index 00000000000..bc3bdb20e02 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useLayoutEffect, useState } from 'react' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' + +import { decodeBrowserScreencastFrame } from '../../../../../shared/browser-screencast-protocol' +import type { BrowserScreencastFrameMetadata } from '../../../../../shared/browser-screencast-protocol' +import type { BrowserTabInfo } from '../../../../../shared/runtime-types' +import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { + RemoteBrowserStreamToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' +import { useRemoteBrowserStreamActivation } from './use-remote-browser-stream-activation' +import { + decodeRemoteBrowserFrameUrl, + getRemoteBrowserDeviceScaleFactor, + readRemoteCssViewportSize, + type RemoteBrowserPaneNotice, + type RemoteBrowserRuntimeTarget, + type RemoteBrowserStreamBridge +} from './remote-browser-page-input-model' + +export function useRemoteBrowserPageStream({ + activeRuntimeEnvironmentId, + browserPageId, + isActive, + lifecycle, + runtimeWorktree, + runtimeTarget, + remoteViewportRef, + remoteViewportSizeRef, + remoteCssViewportSizeRef, + remoteViewportTimerRef, + streamFrameUrlRef, + pendingFrameDecodeRef, + streamBridgeRef, + isActiveRef, + applyTabInfo, + clearStreamFrame, + closeMissingRemotePage, + clearPendingRemoteWheel, + setPaneNotice, + setPaneBusy, + setFrameUrl, + setFrameMetadata +}: { + activeRuntimeEnvironmentId: string + browserPageId: string + isActive: boolean + lifecycle: RemoteBrowserStreamLifecycle + runtimeWorktree: string + runtimeTarget: () => RemoteBrowserRuntimeTarget | null + remoteViewportRef: React.RefObject + remoteViewportSizeRef: React.MutableRefObject + remoteCssViewportSizeRef: React.MutableRefObject + remoteViewportTimerRef: React.MutableRefObject + streamFrameUrlRef: React.MutableRefObject + pendingFrameDecodeRef: React.MutableRefObject + streamBridgeRef: React.MutableRefObject + isActiveRef: React.RefObject + applyTabInfo: (tab: Pick) => void + clearStreamFrame: () => void + closeMissingRemotePage: (remotePageId?: string | null) => void + clearPendingRemoteWheel: () => void + setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void + setPaneBusy: (busy: boolean) => void + setFrameUrl: (url: string | null) => void + setFrameMetadata: (metadata: BrowserScreencastFrameMetadata | null) => void +}): { + reconnectRemoteStream: () => void +} { + // Bumped by Reconnect to re-run the open effect from scratch. See reconnectRemoteStream. + const [reopenNonce, setReopenNonce] = useState(0) + + const rememberRemoteViewportSize = useCallback( + (next: RemoteBrowserViewportSize): RemoteBrowserViewportSize => { + const prev = remoteViewportSizeRef.current + if ( + !prev || + Math.abs(prev.width - next.width) > 3 || + Math.abs(prev.height - next.height) > 3 + ) { + remoteViewportSizeRef.current = next + return next + } + return prev + }, + [remoteViewportSizeRef] + ) + + const readCurrentRemoteViewportSize = useCallback((): RemoteBrowserViewportSize | null => { + const element = remoteViewportRef.current + if (!element) { + return null + } + const rect = element.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + return null + } + return { + width: Math.max(320, Math.round(rect.width)), + height: Math.max(240, Math.round(rect.height)) + } + }, [remoteViewportRef]) + + const readRemoteViewportSize = useCallback((): RemoteBrowserViewportSize | null => { + const next = readCurrentRemoteViewportSize() + return next ? rememberRemoteViewportSize(next) : remoteViewportSizeRef.current + }, [readCurrentRemoteViewportSize, rememberRemoteViewportSize, remoteViewportSizeRef]) + + const waitForRemoteViewportSize = + useCallback(async (): Promise => { + for (let i = 0; i < 3; i += 1) { + const next = readCurrentRemoteViewportSize() + if (next) { + return rememberRemoteViewportSize(next) + } + await new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()) + }) + } + return readRemoteViewportSize() + }, [readCurrentRemoteViewportSize, readRemoteViewportSize, rememberRemoteViewportSize]) + + const syncRemoteViewport = useCallback( + async (pageId: string): Promise => { + const target = runtimeTarget() + const size = readRemoteViewportSize() + if (!target || !size) { + return + } + await callRuntimeRpc( + target, + 'browser.viewport', + { + worktree: runtimeWorktree, + page: pageId, + width: size.width, + height: size.height, + deviceScaleFactor: getRemoteBrowserDeviceScaleFactor(), + mobile: false + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + try { + // Why: the streamed bitmap can include the host compositor surface, but CDP input wants the guest page's CSS viewport coords. + const viewport = await callRuntimeRpc( + target, + 'browser.eval', + { + worktree: runtimeWorktree, + page: pageId, + expression: 'JSON.stringify({ width: window.innerWidth, height: window.innerHeight })' + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + remoteCssViewportSizeRef.current = readRemoteCssViewportSize(viewport) ?? size + } catch { + remoteCssViewportSizeRef.current = size + } + }, + [readRemoteViewportSize, remoteCssViewportSizeRef, runtimeTarget, runtimeWorktree] + ) + + useEffect(() => { + if (!isActive) { + return + } + const element = remoteViewportRef.current + if (!element) { + return + } + const scheduleSync = (): void => { + readRemoteViewportSize() + if (remoteViewportTimerRef.current !== null) { + window.clearTimeout(remoteViewportTimerRef.current) + } + remoteViewportTimerRef.current = window.setTimeout(() => { + remoteViewportTimerRef.current = null + const pageId = lifecycle.tokens.remotePage + if (!pageId || !isActiveRef.current) { + return + } + void syncRemoteViewport(pageId) + .then(() => lifecycle.restartForViewport(pageId)) + .catch(() => {}) + }, 150) + } + scheduleSync() + const observer = new ResizeObserver(scheduleSync) + observer.observe(element) + return () => { + observer.disconnect() + if (remoteViewportTimerRef.current !== null) { + window.clearTimeout(remoteViewportTimerRef.current) + remoteViewportTimerRef.current = null + } + } + }, [ + isActive, + isActiveRef, + lifecycle, + readRemoteViewportSize, + remoteViewportRef, + remoteViewportTimerRef, + syncRemoteViewport + ]) + + const updateStreamFrame = useCallback( + (token: RemoteBrowserStreamToken, bytes: Uint8Array): void => { + if (!lifecycle.tokens.isCurrentStreamToken(token)) { + return + } + const frame = decodeBrowserScreencastFrame(bytes) + if (!frame) { + return + } + const imageBuffer = frame.image.buffer.slice( + frame.image.byteOffset, + frame.image.byteOffset + frame.image.byteLength + ) as ArrayBuffer + const nextUrl = URL.createObjectURL( + new Blob([imageBuffer], { type: `image/${frame.format}` }) + ) + const decodeGeneration = pendingFrameDecodeRef.current + 1 + pendingFrameDecodeRef.current = decodeGeneration + void decodeRemoteBrowserFrameUrl(nextUrl) + .then(() => { + if ( + pendingFrameDecodeRef.current !== decodeGeneration || + !lifecycle.tokens.isCurrentStreamToken(token) + ) { + URL.revokeObjectURL(nextUrl) + return + } + const prevUrl = streamFrameUrlRef.current + streamFrameUrlRef.current = nextUrl + setFrameMetadata(frame.metadata) + setFrameUrl(nextUrl) + setPaneBusy(false) + if (prevUrl) { + URL.revokeObjectURL(prevUrl) + } + }) + .catch(() => { + URL.revokeObjectURL(nextUrl) + }) + }, + [ + lifecycle, + pendingFrameDecodeRef, + setFrameMetadata, + setFrameUrl, + setPaneBusy, + streamFrameUrlRef + ] + ) + + // Publish only callbacks from a committed render. + useLayoutEffect(() => { + streamBridgeRef.current = { + applyTabInfo, + clearFrame: clearStreamFrame, + handleFrameBytes: updateStreamFrame, + closeMissingRemotePage, + waitForViewportSize: waitForRemoteViewportSize, + syncViewport: syncRemoteViewport + } + }, [ + applyTabInfo, + clearStreamFrame, + closeMissingRemotePage, + streamBridgeRef, + syncRemoteViewport, + updateStreamFrame, + waitForRemoteViewportSize + ]) + + const reconnectRemoteStream = useCallback((): void => { + // No status write here: bumping the nonce re-runs the open effect, and open() publishes + // 'opening'. Setting it from two places is how the old three-variable version drifted. + setPaneNotice(null) + // Why re-run the whole open effect rather than resume the stream: reconnect has to work in the + // cases where there is nothing to resume — the remote page was never created, or the very first + // open failed. Resuming a token only covers a stream that once existed. + setReopenNonce((nonce) => nonce + 1) + }, [setPaneNotice]) + + useRemoteBrowserStreamActivation({ + activeRuntimeEnvironmentId, + browserPageId, + clearPendingRemoteWheel, + isActive, + lifecycle, + reopenNonce, + runtimeWorktree + }) + + return { reconnectRemoteStream } +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-wheel.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-wheel.ts new file mode 100644 index 00000000000..cd16e23b002 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-wheel.ts @@ -0,0 +1,191 @@ +import { useCallback, useEffect } from 'react' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { RemoteBrowserOperationToken } from './remote-browser-stream-tokens' +import { + WHEEL_DELTA_LINE, + WHEEL_DELTA_PAGE, + type PendingRemoteBrowserWheel, + type RemoteBrowserPaneNotice, + type RemoteBrowserRuntimeTarget +} from './remote-browser-page-input-model' + +export function useRemoteBrowserPageWheel({ + busy, + imageRef, + remoteViewportRef, + frameUrl, + runtimeTarget, + lifecycle, + runtimeWorktree, + getRemoteImagePoint, + enqueueRemoteInput, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + scheduleRemoteTabInfoRefresh, + setPaneNotice, + pendingRemoteWheelRef, + remoteWheelFrameRef, + remoteWheelInFlightRef +}: { + busy: boolean + imageRef: React.RefObject + remoteViewportRef: React.RefObject + frameUrl: string | null + runtimeTarget: () => RemoteBrowserRuntimeTarget | null + lifecycle: RemoteBrowserStreamLifecycle + runtimeWorktree: string + getRemoteImagePoint: (event: { + clientX: number + clientY: number + }) => { x: number; y: number } | null + enqueueRemoteInput: (operation: () => Promise) => Promise + createRemoteOperationToken: (remotePageId?: string | null) => RemoteBrowserOperationToken | null + isCurrentRemoteOperationToken: (token: RemoteBrowserOperationToken) => boolean + closeMissingRemotePage: (remotePageId?: string | null) => void + scheduleRemoteTabInfoRefresh: (token: RemoteBrowserOperationToken, delayMs?: number) => void + setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void + pendingRemoteWheelRef: React.MutableRefObject + remoteWheelFrameRef: React.MutableRefObject + remoteWheelInFlightRef: React.MutableRefObject +}): void { + const schedulePendingRemoteWheel = useCallback((): void => { + if (remoteWheelFrameRef.current !== null || remoteWheelInFlightRef.current) { + return + } + remoteWheelFrameRef.current = window.requestAnimationFrame(() => { + remoteWheelFrameRef.current = null + const pending = pendingRemoteWheelRef.current + if (!pending || remoteWheelInFlightRef.current) { + return + } + pendingRemoteWheelRef.current = null + remoteWheelInFlightRef.current = true + const { target, pageId, operationToken, point, dx, dy } = pending + const params = { worktree: runtimeWorktree, page: pageId } + void enqueueRemoteInput(async () => { + if (!isCurrentRemoteOperationToken(operationToken)) { + return + } + try { + await callRuntimeRpc( + target, + 'browser.mouseMove', + { ...params, x: point.x, y: point.y }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + await callRuntimeRpc( + target, + 'browser.mouseWheel', + { + ...params, + dx, + dy + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + scheduleRemoteTabInfoRefresh(operationToken, 400) + } catch (error) { + if (isCurrentRemoteOperationToken(operationToken)) { + if (isRemoteBrowserPageMissingError(error)) { + closeMissingRemotePage(pageId) + return + } + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote scroll failed.' + }) + } + } + }).finally(() => { + remoteWheelInFlightRef.current = false + if (pendingRemoteWheelRef.current) { + schedulePendingRemoteWheel() + } + }) + }) + }, [ + closeMissingRemotePage, + enqueueRemoteInput, + isCurrentRemoteOperationToken, + pendingRemoteWheelRef, + remoteWheelFrameRef, + remoteWheelInFlightRef, + scheduleRemoteTabInfoRefresh, + setPaneNotice, + runtimeWorktree + ]) + + const handleRemoteScreenshotWheel = useCallback( + (event: WheelEvent): void => { + if (busy) { + event.preventDefault() + return + } + const target = runtimeTarget() + const pageId = lifecycle.tokens.remotePage + const operationToken = pageId ? createRemoteOperationToken(pageId) : null + const point = getRemoteImagePoint(event) + if (!target || !pageId || !operationToken || !point) { + return + } + event.preventDefault() + setPaneNotice(null) + const deltaMultiplier = + event.deltaMode === WHEEL_DELTA_LINE + ? 16 + : event.deltaMode === WHEEL_DELTA_PAGE + ? (remoteViewportRef.current?.clientHeight ?? 800) + : 1 + const dx = Math.round(event.deltaX * deltaMultiplier) + const dy = Math.round(event.deltaY * deltaMultiplier) + if (dx === 0 && dy === 0) { + return + } + const current = pendingRemoteWheelRef.current + const sameTarget = + current?.target.environmentId === target.environmentId && + current.pageId === pageId && + current.operationToken.generation === operationToken.generation + pendingRemoteWheelRef.current = sameTarget + ? { + ...current, + point, + dx: current.dx + dx, + dy: current.dy + dy + } + : { + target, + pageId, + operationToken, + point, + dx, + dy + } + schedulePendingRemoteWheel() + }, + [ + busy, + createRemoteOperationToken, + getRemoteImagePoint, + lifecycle, + pendingRemoteWheelRef, + remoteViewportRef, + runtimeTarget, + schedulePendingRemoteWheel, + setPaneNotice + ] + ) + + useEffect(() => { + const image = imageRef.current + if (!image || !frameUrl) { + return + } + // Why: React binds wheel listeners passively in Chromium, so bind natively non-passive to preventDefault scroll. + image.addEventListener('wheel', handleRemoteScreenshotWheel, { passive: false }) + return () => image.removeEventListener('wheel', handleRemoteScreenshotWheel) + }, [frameUrl, handleRemoteScreenshotWheel, imageRef]) +} diff --git a/src/renderer/src/components/browser-pane/use-remote-browser-stream-activation.test.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts similarity index 97% rename from src/renderer/src/components/browser-pane/use-remote-browser-stream-activation.test.ts rename to src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts index 70102d402cf..54cfa5d067e 100644 --- a/src/renderer/src/components/browser-pane/use-remote-browser-stream-activation.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts @@ -2,7 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { resetStaleDocumentVisibilityForTesting } from '../terminal-pane/stale-document-visibility' +import { resetStaleDocumentVisibilityForTesting } from '../../terminal-pane/stale-document-visibility' import { WINDOW_STREAM_PARK_DELAY_MS } from '@/hooks/use-window-stream-visibility' import { useRemoteBrowserStreamActivation } from './use-remote-browser-stream-activation' diff --git a/src/renderer/src/components/browser-pane/use-remote-browser-stream-activation.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts similarity index 100% rename from src/renderer/src/components/browser-pane/use-remote-browser-stream-activation.ts rename to src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 5c9b44b5ac1..85d5a2fe943 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -216,7 +216,7 @@ describe('feature interaction writer boundaries', () => { }) it('records browser annotation agent handoff only from the prompt-delivered callback', () => { - const source = componentSource('browser-pane/BrowserPane.tsx') + const source = componentSource('browser-pane/annotate/use-browser-page-annotation-send.ts') expect( source.match(/recordFeatureInteraction\('browser-annotations-sent-to-agent'\)/g) ).toHaveLength(1) diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx index 7e1b8d7a7a2..d00d2640cf2 100644 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { FloatingBrowserSlot } from './FloatingBrowserSlot' -import { getBrowserOverlaySlotViewport } from '@/components/browser-pane/browser-page-viewport' +import { getBrowserOverlaySlotViewport } from '@/components/browser-pane/host-guest/browser-page-viewport' import type { BrowserTab } from '../../../../shared/browser-workspace-types' // Why: BrowserPane mounts a real Electron we can't run in jsdom; stub diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx index 76b819f0876..3af3493db0b 100644 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react' import BrowserPane from '@/components/browser-pane/BrowserPane' -import { registerBrowserOverlaySlotViewport } from '@/components/browser-pane/browser-page-viewport' +import { registerBrowserOverlaySlotViewport } from '@/components/browser-pane/host-guest/browser-page-viewport' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' // Why: BrowserPane mounts its persistent Electron into a slot viewport diff --git a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts index cd585549100..8a16210a3b8 100644 --- a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts +++ b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts @@ -10,7 +10,7 @@ const MAIN_CSS = resolve(__dirname, '../assets/main.css') const HOVER_REVEAL_FILES = [ resolve(__dirname, 'activity/ActivityPrototypePage.tsx'), - resolve(__dirname, 'browser-pane/BrowserPane.tsx'), + resolve(__dirname, 'browser-pane/annotate/browser-page-annotation-tray.tsx'), resolve(__dirname, 'dashboard/DashboardAgentRow.tsx'), resolve(__dirname, 'dashboard/DashboardAgentRowTrailingControls.tsx'), resolve(__dirname, 'editor/CombinedDiffViewer.tsx'), diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 951f2f9f132..fe6b05ef69a 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -134,7 +134,7 @@ vi.mock('@/components/ui/tooltip', () => ({ } })) -vi.mock('../browser-pane/browser-runtime', () => ({ +vi.mock('../browser-pane/describe-page/live-browser-url-registry', () => ({ getLiveBrowserUrl: () => null })) diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 2ea3d2d42e5..8c577306f37 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -23,7 +23,7 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { redactKagiSessionToken } from '../../../../shared/browser-url' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab' -import { getLiveBrowserUrl } from '../browser-pane/browser-runtime' +import { getLiveBrowserUrl } from '../browser-pane/describe-page/live-browser-url-registry' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { ACTIVE_TAB_INDICATOR_CLASSES, diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx index d96d28b89d2..875d2c60ad7 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx @@ -62,7 +62,7 @@ vi.mock('@/lib/simulator-tab-palette-activation', () => ({ vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: activationMocks.focusTerminalTabSurface })) -vi.mock('@/components/browser-pane/browser-focus', () => ({ +vi.mock('@/components/browser-pane/host-guest/browser-focus', () => ({ requestBrowserFocus: activationMocks.requestBrowserFocus })) diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts index f9ec2cd76e5..c346fe2997a 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts @@ -23,7 +23,7 @@ vi.mock('@/lib/simulator-tab-palette-activation', () => ({ vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: mocks.focusTerminalTabSurface })) -vi.mock('@/components/browser-pane/browser-focus', () => ({ +vi.mock('@/components/browser-pane/host-guest/browser-focus', () => ({ requestBrowserFocus: mocks.requestBrowserFocus })) diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts index ceeabe7d8a7..3c5aa14cd0b 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts @@ -1,7 +1,7 @@ // Routes an omnibox switch row to the matching palette activation and reports // how the destination should take keyboard focus once the menu closes. -import { requestBrowserFocus } from '@/components/browser-pane/browser-focus' +import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus' import { translate } from '@/i18n/i18n' import { activateBrowserPagePaletteResult } from '@/lib/browser-page-palette-activation' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 05579cb7b4d..63d6a30040a 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -112,7 +112,7 @@ vi.mock('@/store/selectors', () => ({ useWorktreeById: () => ({ path: '/repo', repoId: 'repo-1' }) })) -vi.mock('../browser-pane/browser-runtime', () => ({ +vi.mock('../browser-pane/describe-page/live-browser-url-registry', () => ({ getLiveBrowserUrl: () => 'https://live.example/not-the-tab-label' })) diff --git a/src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts b/src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts index b9855d04b51..84c77c1888c 100644 --- a/src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts +++ b/src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, type RefObject } from 'react' -import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' +import { acquireWebviewsDragPassthrough } from '../browser-pane/host-guest/webview-registry' import { installTabDragMissedEndListeners } from './tab-drag-missed-end-listeners' /** Global side effects a tab drag holds while in flight: webview pointer diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index 2b372c2c2c4..bdd5edd97af 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -16,7 +16,7 @@ import { useTabDragSplit } from './useTabDragSplit' -vi.mock('../browser-pane/webview-registry', () => ({ +vi.mock('../browser-pane/host-guest/webview-registry', () => ({ acquireWebviewsDragPassthrough: vi.fn(() => vi.fn()) })) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 3dc04d3c323..bf937b28d36 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -146,7 +146,7 @@ import { type CloseTerminalPaneDetail, type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' -import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' +import { acquireWebviewsDragPassthrough } from '../browser-pane/host-guest/webview-registry' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { diff --git a/src/renderer/src/hooks/modal-return-focus-action.ts b/src/renderer/src/hooks/modal-return-focus-action.ts index d86487f9e36..4e516001bfc 100644 --- a/src/renderer/src/hooks/modal-return-focus-action.ts +++ b/src/renderer/src/hooks/modal-return-focus-action.ts @@ -1,4 +1,4 @@ -import type { BrowserFocusTarget } from '../components/browser-pane/browser-focus' +import type { BrowserFocusTarget } from '../components/browser-pane/host-guest/browser-focus' // The surface that held focus before a modal (QuickOpen, Cmd+J, ...) opened. // Captured at open time because Radix steals document focus once the dialog diff --git a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts index c644590041f..f4d274a3838 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts @@ -17,7 +17,7 @@ describe('browser navigation updates', () => { }) const harness = await loadIpcEventsHarness(storeState) const { clearLiveBrowserUrl, getLiveBrowserUrl } = - await import('@/components/browser-pane/browser-runtime') + await import('@/components/browser-pane/describe-page/live-browser-url-registry') readLiveUrl = getLiveBrowserUrl harness.useIpcEvents() diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts index 7df40ebaf4b..fc7afbead90 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts @@ -109,7 +109,7 @@ describe('useIpcEvents browser tab create routing', () => { getState: () => state } })) - vi.doMock('@/components/browser-pane/browser-automation-visibility', () => ({ + vi.doMock('@/components/browser-pane/host-guest/browser-automation-visibility', () => ({ acquireBrowserAutomationVisibility, releaseBrowserAutomationVisibility })) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 55d9cd062eb..9a60936b5ea 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -85,12 +85,12 @@ import { hydrateBrowserDrivers, setDriverForBrowserPage } from '@/lib/pane-manager/browser-mobile-driver-state' -import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' -import { rememberLiveBrowserUrl } from '@/components/browser-pane/browser-runtime' +import { destroyPersistentWebview } from '@/components/browser-pane/host-guest/webview-registry' +import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' import { acquireBrowserAutomationVisibility, releaseBrowserAutomationVisibility -} from '@/components/browser-pane/browser-automation-visibility' +} from '@/components/browser-pane/host-guest/browser-automation-visibility' import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' diff --git a/src/renderer/src/hooks/useModalReturnFocus.test.tsx b/src/renderer/src/hooks/useModalReturnFocus.test.tsx index 95aff445e94..76004c38bb5 100644 --- a/src/renderer/src/hooks/useModalReturnFocus.test.tsx +++ b/src/renderer/src/hooks/useModalReturnFocus.test.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '../store' import { focusTerminalTabSurface } from '../lib/focus-terminal-tab-surface' -import { ORCA_BROWSER_FOCUS_REQUEST_EVENT } from '../components/browser-pane/browser-focus' +import { ORCA_BROWSER_FOCUS_REQUEST_EVENT } from '../components/browser-pane/host-guest/browser-focus' import { useModalReturnFocus } from './useModalReturnFocus' vi.mock('../lib/focus-terminal-tab-surface', () => ({ diff --git a/src/renderer/src/hooks/useModalReturnFocus.ts b/src/renderer/src/hooks/useModalReturnFocus.ts index 02604c8094c..14d7c3fe822 100644 --- a/src/renderer/src/hooks/useModalReturnFocus.ts +++ b/src/renderer/src/hooks/useModalReturnFocus.ts @@ -6,7 +6,7 @@ import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, queueBrowserFocusRequest, type BrowserFocusRequestDetail -} from '../components/browser-pane/browser-focus' +} from '../components/browser-pane/host-guest/browser-focus' import { resolveModalReturnFocusAction, type ModalReturnFocusSurface diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d267d9f6498..04dbbcd1c29 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14773,7 +14773,8 @@ "a6914ee43f": "Take back", "f4ecd61552": "This tab is being controlled from your phone. Take back to use it on desktop.", "d9768ec642": "Browser input is paused", - "20539eca03": "Mobile is driving this browser" + "20539eca03": "Mobile is driving this browser", + "7c31b0da94": "Couldn't take back this tab. Check the phone session, then try again." }, "BrowserPane": { "1ded0d3168": "Copy Screenshot", @@ -14913,6 +14914,35 @@ } } } + }, + "annotate": { + "use": { + "browser": { + "page": { + "grab": { + "annotations": { + "0c7b9b2b7a": "Copied", + "c937229f19": "Screenshotted", + "1f5cb19034": "Annotation added" + } + } + } + } + } + }, + "navigate": { + "use": { + "browser": { + "page": { + "navigation": { + "downloads": { + "8683b84b9e": "Browser page is not ready for file drops.", + "22272f2784": "Drop files over the browser page, not the toolbar." + } + } + } + } + } } }, "profile": { diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 6e96efecf0c..47ee857eb1b 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -46,7 +46,7 @@ describe('renderer crash diagnostics', () => { } } }) - vi.doMock('../components/browser-pane/webview-registry', () => ({ + vi.doMock('../components/browser-pane/host-guest/webview-registry', () => ({ getBrowserWebviewMemoryProfile: () => ({ browserWebviewCount: 4, registeredBrowserGuestCount: 3 diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index 78d1daa76c6..25a6b75caae 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -5,7 +5,7 @@ import type { import { getBrowserWebviewMemoryProfile, type BrowserWebviewMemoryProfile -} from '../components/browser-pane/webview-registry' +} from '../components/browser-pane/host-guest/webview-registry' import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' import { collectRendererMemoryProfileCounts } from './renderer-memory-profile' diff --git a/src/renderer/src/lib/pane-manager/browser-mobile-driver-state.ts b/src/renderer/src/lib/pane-manager/browser-mobile-driver-state.ts index ff828536ac2..dd95a026d30 100644 --- a/src/renderer/src/lib/pane-manager/browser-mobile-driver-state.ts +++ b/src/renderer/src/lib/pane-manager/browser-mobile-driver-state.ts @@ -5,6 +5,9 @@ export type BrowserDriverState = RuntimeBrowserDriverState const driverByBrowserPageId = new Map() +// Why: a shared instance keeps getDriverForBrowserPage referentially stable for useSyncExternalStore snapshots. +export const IDLE_BROWSER_DRIVER: BrowserDriverState = { kind: 'idle' } + type BrowserDriverChangeEvent = { browserPageId: string driver: BrowserDriverState @@ -55,7 +58,14 @@ export function setDriverForBrowserPage(browserPageId: string, driver: BrowserDr } export function getDriverForBrowserPage(browserPageId: string): BrowserDriverState { - return driverByBrowserPageId.get(browserPageId) ?? { kind: 'idle' } + return driverByBrowserPageId.get(browserPageId) ?? IDLE_BROWSER_DRIVER +} + +export function useBrowserDriverForPage( + browserPageId: string | null | undefined +): BrowserDriverState { + useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + return browserPageId ? getDriverForBrowserPage(browserPageId) : IDLE_BROWSER_DRIVER } export function isBrowserPageMobileDriven(browserPageId: string): boolean { diff --git a/src/renderer/src/store/slices/browser-webview-cleanup.test.ts b/src/renderer/src/store/slices/browser-webview-cleanup.test.ts index 3f7c69a4d29..fa18e2e54ae 100644 --- a/src/renderer/src/store/slices/browser-webview-cleanup.test.ts +++ b/src/renderer/src/store/slices/browser-webview-cleanup.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' -vi.mock('../../components/browser-pane/webview-registry', () => ({ +vi.mock('../../components/browser-pane/host-guest/webview-registry', () => ({ destroyPersistentWebview: vi.fn() })) @@ -11,12 +11,12 @@ import { destroyWorkspaceWebviews, destroyWorktreeBrowserGuests } from './browser-webview-cleanup' -import { destroyPersistentWebview } from '../../components/browser-pane/webview-registry' +import { destroyPersistentWebview } from '../../components/browser-pane/host-guest/webview-registry' import { forgetExplicitBrowserPageZoomLevel, getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel -} from '../../components/browser-pane/browser-page-zoom' +} from '../../components/browser-pane/host-guest/browser-page-zoom' function workspace(id: string): BrowserWorkspace { return { diff --git a/src/renderer/src/store/slices/browser-webview-cleanup.ts b/src/renderer/src/store/slices/browser-webview-cleanup.ts index 7b835841c3a..25510e64f05 100644 --- a/src/renderer/src/store/slices/browser-webview-cleanup.ts +++ b/src/renderer/src/store/slices/browser-webview-cleanup.ts @@ -2,11 +2,11 @@ import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-w import { destroyPersistentWebview, moveFocusToRendererBeforeFocusedWebviewHidden -} from '../../components/browser-pane/webview-registry' +} from '../../components/browser-pane/host-guest/webview-registry' import { getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel -} from '../../components/browser-pane/browser-page-zoom' +} from '../../components/browser-pane/host-guest/browser-page-zoom' export { moveFocusToRendererBeforeFocusedWebviewHidden } diff --git a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts index 6e9cd99f733..c2519d6909a 100644 --- a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts +++ b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts @@ -3,7 +3,7 @@ import type { AppState } from '../types' import { registerPersistentWebview, unregisterPersistentWebview -} from '../../components/browser-pane/webview-registry' +} from '../../components/browser-pane/host-guest/webview-registry' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { makeDetectedResult } from './worktrees-detected-listing-fixtures' import { diff --git a/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts b/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts index 169c95e0631..de24f0f4e6d 100644 --- a/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts +++ b/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts @@ -17,7 +17,7 @@ import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH } from '../../src/renderer/src/components/browser-pane/browser-address-bar-expansion' +import { BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH } from '../../src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion' // Why: the toolbar must land in a band — squeezed enough that the inline field // collapses, roomy enough that the overlay itself has somewhere to go. Target diff --git a/tests/e2e/browser-guest-crash-recovery.spec.ts b/tests/e2e/browser-guest-crash-recovery.spec.ts index 2d3570ca33f..d2bcfae1a22 100644 --- a/tests/e2e/browser-guest-crash-recovery.spec.ts +++ b/tests/e2e/browser-guest-crash-recovery.spec.ts @@ -3,10 +3,10 @@ import { createServer } from 'node:http' import os from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' -import type { Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' import { ensureTerminalVisible, getActiveWorktreeId, waitForActiveWorktree } from './helpers/store' -import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../../src/renderer/src/components/browser-pane/browser-page-guest-recovery' +import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../../src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery' import { crashGuestRenderer, listRegisteredBrowserPages, @@ -679,6 +679,37 @@ test('attachment keeps recovery error until document readiness', async ({ expect(recoveryErrorCode).toBe(BROWSER_GUEST_RECOVERY_ERROR_CODE) }) +async function occludeHeadedWindow(electronApp: ElectronApplication, page: Page): Promise { + const host = await electronApp.browserWindow(page) + await host.evaluate((window) => window.minimize()) + const deadline = Date.now() + 2_000 + let minimized = false + while (!minimized && Date.now() < deadline) { + minimized = await host.evaluate((window) => window.isMinimized()) + if (!minimized) { + await page.waitForTimeout(50) + } + } + if (!minimized) { + // Why: Xvfb has no window manager, so Electron minimize is a no-op on + // Linux CI (especially frameless). Hide still unpaints the guest compositor. + await host.evaluate((window) => window.hide()) + await expect.poll(() => host.evaluate((window) => window.isVisible())).toBe(false) + } +} + +async function revealHeadedWindow(electronApp: ElectronApplication, page: Page): Promise { + const host = await electronApp.browserWindow(page) + await host.evaluate((window) => { + window.restore() + window.show() + window.focus() + }) + await expect + .poll(() => host.evaluate((window) => window.isVisible() && !window.isMinimized())) + .toBe(true) +} + test('minimized browser guest stays painted and registered after restore @headful', async ({ electronApp, orcaPage, @@ -693,21 +724,8 @@ test('minimized browser guest stays painted and registered after restore @headfu .toMatchObject({ marker: 'painted-file-guest', url: fixtureUrl }) const before = await readBrowserGuestState(orcaPage, browserTab.id) - await electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]?.minimize() - }) - await expect - .poll(() => - electronApp.evaluate(({ BrowserWindow }) => - Boolean(BrowserWindow.getAllWindows()[0]?.isMinimized()) - ) - ) - .toBe(true) - await electronApp.evaluate(({ BrowserWindow }) => { - const window = BrowserWindow.getAllWindows()[0] - window?.restore() - window?.show() - }) + await occludeHeadedWindow(electronApp, orcaPage) + await revealHeadedWindow(electronApp, orcaPage) await expect .poll(() => readBrowserGuestState(orcaPage, browserTab.id))